前端码农
IP:河南
0关注数
0粉丝数
0获得的赞
工作年
编辑资料
链接我:

创作·73

全部
问答
动态
项目
学习
专栏
前端码农

浏览器中的网络:http1、http2、http3都有些什么优缺点?

浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第六篇HTTP是浏览器中最重要且使用最多的协议,是浏览器和服务器之间的通信语言,也是互联网的基石。而随着浏览器的发展,HTTP为了能适应新的形式也在持续进化,从http0.9到http3。超文本传输协议HTTP/0.9HTTP/0.9是于1991年提出的,主要用于学术交流,需求很简单——用来在网络之间传递HTML超文本的内容,所以被称为超文本传输协议。 它的实现也很简单,采用了基于请求响应的模式,从客户端发出请求,服务器返回数据。HTTP/0.9的实现有三个特点。只有一个请求行,并没有HTTP请求头和请求体,因为只需要一个请求行就可以完整表达客户端的需求了。服务器没有返回头信息,这是因为服务器端并不需要告诉客户端太多信息,只需要返回数据就可以了。返回的文件内容是以ASCII字符流来传输的,因为都是HTML格式的文件,所以使用ASCII字节码来传输是最合适的。HTTP/1.0HTTP/1.0引入了请求头和响应头,它们都是以为Key-Value形式保存的,在HTTP发送请求时,会带上请求头信息,服务器返回数据时,会先返回响应头信息。HTTP/1.0通过请求头和响应头来和服务器进行协商,在发起请求时候会通过HTTP请求头告诉服务器它期待服务器返回什么类型的文件、采取什么形式的压缩、提供什么语言的文件以及文件的具体编码。HTTP/1.0引入了状态码, 有的请求服务器可能无法处理,或者处理出错,这时候就需要告诉浏览器服务器最终处理该请求的情况。状态码是通过响应行的方式来通知浏览器的。HTTP/1.0中提供了Cache机制,用来缓存已经下载过的数据,以减轻服务器的压力。HTTP/1.0的请求头中还加入了用户代理的字段,服务器需要统计客户端的基础信息,比如Windows和macOS的用户数量分别是多少。HTTP/1.11. 改进持久连接: HTTP/1.1中增加了持久连接的方法,它的特点是在一个TCP连接上可以传输多个HTTP请求,只要浏览器或者服务器没有明确断开连接,那么该TCP连接会一直保持。持久连接在HTTP/1.1中是默认开启的,所以不需要专门为了持久连接去HTTP请求头设置信息,如果不想要采用持久连接,可以在HTTP请求头中加上Connection: close。目前浏览器中对于同一个域名,默认允许同时建立6个TCP持久连接。2. 不成熟的HTTP管线化: 持久连接虽然能减少TCP的建立和断开次数,但是它需要等待前面的请求返回之后,才能进行下一次请求。如果TCP通道中的某个请求因为某些原因没有及时返回,那么就会阻塞后面的所有请求,这就是队头阻塞问题。HTTP/1.1中试图通过管线化的技术来解决队头阻塞的问题:将多个HTTP请求整批提交给服务器的技术,虽然可以整批发送请求,不过服务器依然需要根据请求顺序来回复浏览器的请求。3. 提供虚拟主机的支持: 在HTTP/1.0中,每个域名绑定了一个唯一的IP地址,因此一个服务器只能支持一个域名。但是随着虚拟主机技术的发展,需要实现在一台物理主机上绑定多个虚拟主机,每个虚拟主机都有自己的单独的域名,这些单独的域名都公用同一个IP地址。因此,HTTP/1.1的请求头中增加了Host字段,用来表示当前的域名地址,这样服务器就可以根据不同的Host值做不同的处理。4. 对动态生成的内容提供了完美支持: 在设计HTTP/1.0时,需要在响应头中设置完整的数据大小,如Content-Length: 901,这样浏览器就可以根据设置的数据大小来接收数据。不过随着服务器端的技术发展,很多页面的内容都是动态生成的,因此在传输数据之前并不知道最终的数据大小,这就导致了浏览器不知道何时会接收完所有的文件数据。HTTP/1.1通过引入Chunk transfer机制来解决这个问题,服务器会将数据分割成若干个任意大小的数据块,每个数据块发送时会附上上个数据块的长度,最后使用一个零长度的块作为发送数据完成的标志。这样就提供了对动态内容的支持。5. 客户端Cookie、安全机制: HTTP/1.1引入了客户端Cookie机制和安全机制http1的弊端http1的最核心的优化有三点:增加了持久连接;浏览器为每个域名最多同时维护6个TCP持久连接;使用CDN的实现域名分片机制。http1缺点 -- 对带宽的利用率却并不理想,有三个原因:TCP的慢启动: 一旦一个TCP连接建立之后,就进入了发送数据状态,刚开始TCP协议会采用一个非常慢的速度去发送数据,然后慢慢加快发送数据的速度,直到发送数据的速度达到一个理想状态,这个过程称为慢启动。把每个TCP发送数据的过程看成是一辆车的启动过程,当刚进入公路时,会有从0到一个稳定速度的提速过程,TCP的慢启动就类似于该过程。慢启动是TCP为了减少网络拥塞的一种策略,是没有办法改变的。同时开启了多条TCP连接,这些连接会竞争固定的带宽: 比如一个页面有200个文件,使用了3个CDN,那么加载该网页的时候就需要建立6 * 3,也就是18个TCP连接来下载资源;在下载过程中,当发现带宽不足的时候,各个TCP连接就需要动态减慢接收数据的速度。这样就会出现一个问题,因为有的TCP连接下载的是一些关键资源,如CSS文件、JavaScript文件等,而有的TCP连接下载的是图片、视频等普通的资源文件,但是多条TCP连接之间又不能协商让哪些关键资源优先下载,这样就有可能影响那些关键资源的下载速度了。队头阻塞问题: 在HTTP/1.1中使用持久连接时,虽然能公用一个TCP管道,但是在一个管道中同一时刻只能处理一个请求,在当前的请求没有结束之前,其他的请求只能处于阻塞状态。这意味着不能随意在一个管道中发送请求和接收内容。http2http2最重要的特点为多路复用:一个域名只使用一个TCP长连接和消除队头阻塞问题。HTTP/2如何实现多路复用:添加了一个二进制分帧层,以下为http2请求和接收过程。首先,浏览器准备好请求数据,包括了请求行、请求头等信息,如果是POST方法,那么还要有请求体。这些数据经过二进制分帧层处理之后,会被转换为一个个带有请求ID编号的帧,通过协议栈将这些帧发送给服务器。服务器接收到所有帧之后,会将所有相同ID的帧合并为一条完整的请求信息。然后服务器处理该条请求,并将处理的响应行、响应头和响应体分别发送至二进制分帧层。同样,二进制分帧层会将这些响应数据转换为一个个带有请求ID编号的帧,经过协议栈发送给浏览器。浏览器接收到响应帧之后,会根据ID编号将帧的数据提交给对应的请求。HTTP/2其他特性1. 可以设置请求的优先级: HTTP/2提供了请求优先级,可以在发送请求时,标上该请求的优先级,这样服务器接收到请求之后,会优先处理优先级高的请求。2. 服务器推送: 当用户请求一个HTML页面之后,服务器知道该HTML页面会引用几个重要的JavaScript文件和CSS文件,那么在接收到HTML请求之后,附带将要使用的CSS文件和JavaScript文件一并发送给浏览器,这样当浏览器解析完HTML文件之后,就能直接拿到需要的CSS文件和JavaScript文件,这对首次打开页面的速度起到了至关重要的作用。3. 头部压缩: HTTP/2对请求头和响应头进行了压缩。一个HTTP的头文件没有多大,但是浏览器发送请求的时候,基本上都是发送HTTP请求头,很少有请求体的发送,通常情况下页面也有100个左右的资源,如果将这100个请求头的数据压缩为原来的20%,那么传输效率肯定能得到大幅提升。http3/QUIC虽然HTTP/2解决了应用层面的队头阻塞问题,不过和HTTP/1.1一样,HTTP/2依然是基于TCP协议的,而TCP最初就是为了单连接而设计的。除了TCP队头阻塞之外,TCP的握手过程也是影响传输效率的一个重要因素。HTTP/1和HTTP/2都是使用TCP协议来传输的,而如果使用HTTPS的话,还需要使用TLS协议进行安全传输,而使用TLS也需要一个握手过程,这样就需要有两个握手延迟过程。在建立TCP连接的时候,需要和服务器进行三次握手来确认连接成功,也就是说需要在消耗完1.5个RTT之后才能进行数据传输。进行TLS连接,TLS有两个版本——TLS1.2和TLS1.3,每个版本建立连接所花的时间不同,大致是需要1~2个RTT所以,在传输数据之前,就需要花掉3~4个RTT,如果距离过长,是比较耗时的。因此,HTTP/3选择了UDP协议,基于UDP实现了类似于 TCP的多路数据流、传输可靠性等功能,这套功能称为QUIC协议HTTP/3中的QUIC协议集合了以下几点功能:实现了类似TCP的流量控制、传输可靠性的功能。虽然UDP不提供可靠性的传输,但QUIC在UDP的基础之上增加了一层来保证数据可靠性传输。它提供了数据包重传、拥塞控制以及其他一些TCP中存在的特性。集成了TLS加密功能。目前QUIC使用的是TLS1.3,相较于早期版本TLS1.3有更多的优点,其中最重要的一点是减少了握手所花费的RTT个数。实现了HTTP/2中的多路复用功能。和TCP不同,QUIC实现了在同一物理连接上可以有多个独立的逻辑数据流。实现了数据流的单独传输,就解决了TCP中队头阻塞的问题。实现了快速握手功能。由于QUIC是基于UDP的,所以QUIC可以实现使用0-RTT或者1-RTT来建立连接,这意味着QUIC可以用最快的速度来发送和接收数据,这样可以大大提升首次打开页面的速度。HTTP/3的挑战在技术层面,HTTP/3是个近乎完美的协议。不过要将HTTP/3应用到实际环境中依然面临着诸多严峻的挑战,主要来自于以下三个方面。第一,从目前的情况来看,服务器和浏览器端都没有对HTTP/3提供比较完整的支持。第二,部署HTTP/3也存在着非常大的问题。因为系统内核对UDP的优化远远没有达到TCP的优化程度,这也是阻碍QUIC的一个重要原因。第三,中间设备僵化的问题。这些设备对UDP的优化程度远远低于TCP,据统计使用QUIC协议时,大约有3%~7%的丢包率。HTTPS由于HTTP天生“明文”的特点,整个传输过程完全透明,任何人都能够在链路中截获、修改或者伪造请求/响应报文,数据不具有可信性。因此有了HTTPS。通常认为,如果通信过程具备了四个特性,就可以认为是“安全”的,这四个特性是:机密性、完整性,身份认证和不可否认。HTTPS默认端口号443,其他的什么请求-应答模式、报文结构、请求方法、URI、头字段、连接管理等等都完全沿用HTTP。它把HTTP下层的传输协议由TCP/IP换成了SSL/TLS。
0
0
0
浏览量498
前端码农

浏览器渲染原理-浏览器渲染过程

一.进程与线程进程是操作系统资源分配的基本单位,进程中包含线程。线程是由进程所管理的。为了提升浏览器的稳定性和安全性,浏览器采用了多进程模型。浏览器中的(5个)进程浏览器进程:负责界面显示、用户交互、子进程管理,提供存储等。渲染进程:每个也卡都有单独的渲染进程,核心用于渲染页面。网络进程:主要处理网络资源加载(HTML、CSS、JS等)GPU进程:3d绘制,提高性能插件进程: chrome中安装的一些插件二.从输入URL到浏览器显示页面发生了什么?用户输入的是关键字还是URL? 如果是关键字则使用默认搜索引擎生产URL1.浏览器进程的相互调用在浏览器进程中输入url地址开始导航。并准备渲染进程在网络进程中发送请求,将响应后的结果交给渲染进程处理解析页面,加载页面中所需资源渲染完毕,展示结果我们开始细化每一步流程,并且从流程中提取我们可以优化的点。2.URL请求过程浏览器查找当前URL是否存在缓存,如果有缓存、并且缓存未过期,直接从缓存中返回。查看域名是否已经被解析过了,没有解析过进行DNS解析将域名解析成IP地址,并增加端口号如果请求是HTTPS,进行SSL协商利用IP地址进行寻址,请求排队。同一个域名下请求数量不能多余6个。排队后服务器创建TCP链接 (三次握手)利用TCP协议将大文件拆分成数据包进行传输(有序传输),可靠的传输给服务器(丢包重传),服务器收到后按照序号重排数据包 (增加TCP头部,IP头部)发送HTTP请求(请求行,请求头,请求体)HTTP 1.1中支持keep-alive属性,TCP链接不会立即关闭,后续请求可以省去建立链接时间。服务器响应结果(响应行,响应头,响应体)返回状态码为301、302时,浏览器会进行重定向操作。(重新进行导航)返回304则查找缓存。(服务端可以设置强制缓存)通过network Timing 观察请求发出的流程:Queuing: 请求发送前会根据优先级进行排队,同时每个域名最多处理6个TCP链接,超过的也会进行排队,并且分配磁盘空间时也会消耗一定时间。Stalled :请求发出前的等待时间(处理代理,链接复用)DNS lookup :查找DNS的时间initial Connection :建立TCP链接时间SSL: SSL握手时间(SSL协商)Request Sent :请求发送时间(可忽略)Waiting(TTFB) :等待响应的时间,等待返回首个字符的时间Content Dowloaded :用于下载响应的时间蓝色:DOMContentLoaded:DOM构建完成的时间 红色:Load:浏览器所有资源加载完毕本质上,浏览器是方便一般互联网用户通过界面解析和发送HTTP协议的软件3.HTTP发展历程HTTP/0.9 在传输过程中没有请求头和请求体,服务器响应没有返回头信息,内容采用ASCII字符流来进行传输 HTMLHTTP/1.0 增加了请求头和响应头,实现多类型数据传输HTTP/1.1 默认开启持久链接,在一个TCP链接上可以传输多个HTTP请求 , 采用管线化的方式(每个域名最多维护6个TCP持久链接)解决队头阻塞问题 (服务端需要按顺序依次处理请求)。完美支持数据分块传输(chunk transfer),并引入客户端cookie机制、安全机制等。HTTP/2.0 解决网络带宽使用率低 (TCP慢启动,多个TCP竞争带宽,队头阻塞)采用多路复用机制(一个域名使用一个TCP长链接,通过二进制分帧层来实现)。头部压缩(HPACK)、及服务端推送HTTP/3.0 解决TCP队头阻塞问题, 采用QUIC协议。QUIC协议是基于UDP的 (目前:支持和部署是最大的问题)HTTP明文传输,在传输过程中会经历路由器、运营商等环节,数据有可能被窃取或篡改 (安全问题)对比HTTP/1.1 和 HTTP/2 的差异4.渲染流程1.浏览器无法直接使用HTML,需要将HTML转化成DOM树。(document)2.浏览器无法解析纯文本的CSS样式,需要对CSS进行解析,解析成styleSheets。CSSOM(document.styleSeets)3.计算出DOM树中每个节点的具体样式(Attachment)4.创建渲染(布局)树,将DOM树中可见节点,添加到布局树中。并计算节点渲染到页面的坐标位置。(layout)5.通过布局树,进行分层 (根据定位属性、透明属性、transform属性、clip属性等)生产图层树6.将不同图层进行绘制,转交给合成线程处理。最终生产页面,并显示到浏览器上 (Painting,Display)查看layer并对图层进行绘制的列表
0
0
0
浏览量2015
前端码农

面试官: 你对事件循环的理解很不错!所以我总结了下来。

什么是事件循环默认代码从上到下执行,执行环境通过script来执行(宏任务)在代码执行过程中,调用定时器 promise click事件...不会立即执行,需要等待当前代码全部执行完毕给异步方法划分队列,分别存放到微任务(立即存放)和宏任务(时间到了或事情发生了才存放)到队列中script执行完毕后,会清空所有的微任务微任务执行完毕后,会渲染页面(不是每次都调用)再去宏任务队列中看有没有到达时间的,拿出来其中一个执行执行完毕后,按照上述步骤不停的循环例子自动执行的情况 会输出 listener1 listener2 task1 task2如果手动点击click 会一个宏任务取出来一个个执行,先执行click的宏任务,取出微任务去执行。会输出 listener1 task1 listener2 task2console.log(1) async function asyncFunc(){ console.log(2) // await xx ==> promise.resolve(()=>{console.log(3)}).then() // console.log(3) 放到promise.resolve或立即执行 await console.log(3) // 相当于把console.log(4)放到了then promise.resolve(()=>{console.log(3)}).then(()=>{ // console.log(4) // }) // 微任务谁先注册谁先执行 console.log(4) } setTimeout(()=>{console.log(5)}) const promise = new Promise((resolve,reject)=>{ console.log(6) resolve(7) }) promise.then(d=>{console.log(d)}) asyncFunc() console.log(8) // 输出 1 6 2 3 8 7 4 5 1. 浏览器事件循环涉及面试题:异步代码执行顺序?解释一下什么是 Event Loop ?JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变js代码执行过程中会有很多任务,这些任务总的分成两类:同步任务异步任务当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。,我们用导图来说明:我们解释一下这张图:同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。当指定的事情完成时,Event Table会将这个函数移入Event Queue。主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。上述过程会不断重复,也就是常说的Event Loop(事件循环)。那主线程执行栈何时为空呢?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数以上就是js运行的整体流程面试中该如何回答呢? 下面是我个人推荐的回答:首先js 是单线程运行的,在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务当同步事件执行完毕后,再将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行任务队列可以分为宏任务对列和微任务对列,当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。setTimeout(function() { console.log(1) }, 0); new Promise(function(resolve, reject) { console.log(2); resolve() }).then(function() { console.log(3) }); process.nextTick(function () { console.log(4) }) console.log(5)第一轮:主线程开始执行,遇到setTimeout,将setTimeout的回调函数丢到宏任务队列中,在往下执行new Promise立即执行,输出2,then的回调函数丢到微任务队列中,再继续执行,遇到process.nextTick,同样将回调函数扔到微任务队列,再继续执行,输出5,当所有同步任务执行完成后看有没有可以执行的微任务,发现有then函数和nextTick两个微任务,先执行哪个呢?process.nextTick指定的异步任务总是发生在所有异步任务之前,因此先执行process.nextTick输出4然后执行then函数输出3,第一轮执行结束。第二轮:从宏任务队列开始,发现setTimeout回调,输出1执行完毕,因此结果是25431JS 在执行的过程中会产生执行环境,这些执行环境会被顺序的加入到执行栈中。如果遇到异步的代码,会被挂起并加入到 Task(有多种 task) 队列中。一旦执行栈为空,Event Loop 就会从 Task 队列中拿出需要执行的代码并放入执行栈中执行,所以本质上来说 JS 中的异步还是同步行为console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); console.log('script end');不同的任务源会被分配到不同的 Task 队列中,任务源可以分为 微任务(microtask) 和 宏任务(macrotask)。在 ES6 规范中,microtask 称为 jobs,macrotask 称为 taskconsole.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); new Promise((resolve) => { console.log('Promise') resolve() }).then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end'); // script start => Promise => script end => promise1 => promise2 => setTimeout 以上代码虽然 setTimeout 写在 Promise 之前,但是因为 Promise 属于微任务而 setTimeout 属于宏任务微任务process.nextTickpromiseObject.observeMutationObserver宏任务scriptsetTimeoutsetIntervalsetImmediateI/O 网络请求完成、文件读写完成事件UI rendering用户交互事件(比如鼠标点击、滚动页面、放大缩小等)宏任务中包括了 script ,浏览器会先执行一个宏任务,接下来有异步代码的话就先执行微任务所以正确的一次 Event loop 顺序是这样的执行同步代码,这属于宏任务执行栈为空,查询是否有微任务需要执行执行所有微任务必要的话渲染 UI然后开始下一轮 Event loop,执行宏任务中的异步代码通过上述的 Event loop 顺序可知,如果宏任务中的异步代码有大量的计算并且需要操作 DOM 的话,为了更快的响应界面响应,我们可以把操作 DOM 放入微任务中JavaScript 引擎首先从宏任务队列(macrotask queue)中取出第一个任务执行完毕后,再将微任务(microtask queue)中的所有任务取出,按照顺序分别全部执行(这里包括不仅指开始执行时队列里的微任务),如果在这一步过程中产生新的微任务,也需要执行;然后再从宏任务队列中取下一个,执行完毕后,再次将 microtask queue 中的全部取出,循环往复,直到两个 queue 中的任务都取完。总结起来就是:一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务。2. Node 中的 Event loop当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,这些脚本会进行 API 异步调用,process.nextTick() 方法会开始处理事件循环。下面就是 Node.js 官网提供的 Eventloop 事件循环参考流程Node 中的 Event loop 和浏览器中的不相同。Node 的 Event loop 分为6个阶段,它们会按照顺序反复运行每次执行执行一个宏任务后会清空微任务(执行顺序和浏览器一致,在node11版本以上)process.nextTick node中的微任务,当前执行栈的底部,优先级比promise要高整个流程分为六个阶段,当这六个阶段执行完一次之后,才可以算得上执行了一次 Eventloop 的循环过程。我们来分别看下这六个阶段都做了哪些事情。Timers 阶段:这个阶段执行 setTimeout 和 setInterval的回调函数,简单理解就是由这两个函数启动的回调函数。I/O callbacks 阶段:这个阶段主要执行系统级别的回调函数,比如 TCP 连接失败的回调。idle,prepare 阶段:仅系统内部使用,你只需要知道有这 2 个阶段就可以。poll 阶段:poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了setTimeout、setInterval、setImmediate 以及一些因为 exception 意外关闭产生的回调)。检索新的 I/O 事件,执行与 I/O 相关的回调,其他情况 Node.js 将在适当的时候在此阻塞。这也是最复杂的一个阶段,所有的事件循环以及回调处理都在这个阶段执行。这个阶段的主要流程如下图所示。check 阶段:setImmediate() 回调函数在这里执行,setImmediate 并不是立马执行,而是当事件循环 poll 中没有新的事件处理时就执行该部分,如下代码所示。const fs = require('fs'); setTimeout(() => { // 新的事件循环的起点 console.log('1'); }, 0); setImmediate( () => { console.log('setImmediate 1'); }); /// fs.readFile 将会在 poll 阶段执行 fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file success'); }); /// 该部分将会在首次事件循环中执行 Promise.resolve().then(()=>{ console.log('poll callback'); }); // 首次事件循环执行 console.log('2');在这一代码中有一个非常奇特的地方,就是 setImmediate 会在 setTimeout 之后输出。有以下几点原因:setTimeout 如果不设置时间或者设置时间为 0,则会默认为 1ms 主流程执行完成后,超过 1ms 时,会将 setTimeout 回调函数逻辑插入到待执行回调函数 poll 队列中; 由于当前 poll 队列中存在可执行回调函数,因此需要先执行完,待完全执行完成后,才会执行check:setImmediate。因此这也验证了这句话,先执行回调函数,再执行 setImmediateclose callbacks 阶段:执行一些关闭的回调函数,如 socket.on('close', ...)除了把 Eventloop 的宏任务细分到不同阶段外。node 还引入了一个新的任务队列 Process.nextTick()可以认为,Process.nextTick() 会在上述各个阶段结束时,在进入下一个阶段之前立即执行(优先级甚至超过 microtask 队列)事件循环的主要包含微任务和宏任务。具体是怎么进行循环的呢微任务:在 Node.js 中微任务包含 2 种——process.nextTick 和 Promise。微任务在事件循环中优先级是最高的,因此在同一个事件循环中有其他任务存在时,优先执行微任务队列。并且process.nextTick 和 Promise也存在优先级,process.nextTick 高于 Promise宏任务:在 Node.js 中宏任务包含 4 种——setTimeout、setInterval、setImmediate 和 I/O。宏任务在微任务执行之后执行,因此在同一个事件循环周期内,如果既存在微任务队列又存在宏任务队列,那么优先将微任务队列清空,再执行宏任务队列我们可以看到有一个核心的主线程,它的执行阶段主要处理三个核心逻辑。同步代码。将异步任务插入到微任务队列或者宏任务队列中。执行微任务或者宏任务的回调函数。在主线程处理回调函数的同时,也需要判断是否插入微任务和宏任务。根据优先级,先判断微任务队列是否存在任务,存在则先执行微任务,不存在则判断在宏任务队列是否有任务,有则执行。const fs = require('fs'); // 首次事件循环执行 console.log('start'); /// 将会在新的事件循环中的阶段执行 fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file success'); }); setTimeout(() => { // 新的事件循环的起点 console.log('setTimeout'); }, 0); /// 该部分将会在首次事件循环中执行 Promise.resolve().then(()=>{ console.log('Promise callback'); }); /// 执行 process.nextTick process.nextTick(() => { console.log('nextTick callback'); }); // 首次事件循环执行 console.log('end');分析下上面代码的执行过程第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end第一个事件循环主线程发起,因此先执行同步代码,所以先输出 start,然后输出 end;再从上往下分析,遇到微任务,插入微任务队列,遇到宏任务,插入宏任务队列,分析完成后,微任务队列包含:Promise.resolve 和 process.nextTick,宏任务队列包含:fs.readFile 和 setTimeout;先执行微任务队列,但是根据优先级,先执行 process.nextTick 再执行 Promise.resolve,所以先输出 nextTick callback 再输出 Promise callback;再执行宏任务队列,根据宏任务插入先后顺序执行 setTimeout 再执行 fs.readFile,这里需要注意,先执行 setTimeout 由于其回调时间较短,因此回调也先执行,并非是 setTimeout 先执行所以才先执行回调函数,但是它执行需要时间肯定大于 1ms,所以虽然 fs.readFile 先于setTimeout 执行,但是 setTimeout 执行更快,所以先输出 setTimeout ,最后输出 read file success。// 输出结果 start end nextTick callback Promise callback setTimeout read file success当微任务和宏任务又产生新的微任务和宏任务时,又应该如何处理呢?如下代码所示:const fs = require('fs'); setTimeout(() => { // 新的事件循环的起点 console.log('1'); fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file sync success'); }); }, 0); /// 回调将会在新的事件循环之前 fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file success'); }); /// 该部分将会在首次事件循环中执行 Promise.resolve().then(()=>{ console.log('poll callback'); }); // 首次事件循环执行 console.log('2');在上面代码中,有 2 个宏任务和 1 个微任务,宏任务是 setTimeout 和 fs.readFile,微任务是 Promise.resolve。整个过程优先执行主线程的第一个事件循环过程,所以先执行同步逻辑,先输出 2。接下来执行微任务,输出 poll callback。再执行宏任务中的 fs.readFile 和 setTimeout,由于 fs.readFile 优先级高,先执行 fs.readFile。但是处理时间长于 1ms,因此会先执行 setTimeout 的回调函数,输出 1。这个阶段在执行过程中又会产生新的宏任务 fs.readFile,因此又将该 fs.readFile 插入宏任务队列最后由于只剩下宏任务了 fs.readFile,因此执行该宏任务,并等待处理完成后的回调,输出 read file sync success。// 结果 2 poll callback 1 read file success read file sync successProcess.nextick() 和 Vue 的 nextickNode.js 和浏览器端宏任务队列的另一个很重要的不同点是,浏览器端任务队列每轮事件循环仅出队一个回调函数接着去执行微任务队列;而 Node.js 端只要轮到执行某个宏任务队列,则会执行完队列中所有的当前任务,但是当前轮次新添加到队尾的任务则会等到下一轮次才会执行。setTimeout(() => { console.log('setTimeout'); }, 0); setImmediate(() => { console.log('setImmediate'); }) // 这里可能会输出 setTimeout,setImmediate // 可能也会相反的输出,这取决于性能 // 因为可能进入 event loop 用了不到 1 毫秒,这时候会执行 setImmediate // 否则会执行 setTimeout上面介绍的都是 macrotask 的执行情况,microtask 会在以上每个阶段完成后立即执行setTimeout(()=>{ console.log('timer1') Promise.resolve().then(function() { console.log('promise1') }) }, 0) setTimeout(()=>{ console.log('timer2') Promise.resolve().then(function() { console.log('promise2') }) }, 0) // 以上代码在浏览器和 node 中打印情况是不同的 // 浏览器中一定打印 timer1, promise1, timer2, promise2 // node 中可能打印 timer1, timer2, promise1, promise2 // 也可能打印 timer1, promise1, timer2, promise2Node 中的 process.nextTick 会先于其他 microtask 执行setTimeout(() => { console.log("timer1"); Promise.resolve().then(function() { console.log("promise1"); }); }, 0); // poll阶段执行 fs.readFile('./test',()=>{ // 在poll阶段里面 如果有setImmediate优先执行,setTimeout处于事件循环顶端 poll下面就是setImmediate setTimeout(()=>console.log('setTimeout'),0) setImmediate(()=>console.log('setImmediate'),0) }) process.nextTick(() => { console.log("nextTick"); }); // nextTick, timer1, promise1,setImmediate,setTimeout 对于 microtask 来说,它会在以上每个阶段完成前清空 microtask 队列,下图中的 Tick 就代表了 microtask谁来启动这个循环过程,循环条件是什么?当 Node.js 启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的 API、调度定时器,或者 process.nextTick(),然后再开始处理事件循环。因此可以这样理解,Node.js 进程启动后,就发起了一个新的事件循环,也就是事件循环的起点。总结来说,Node.js 事件循环的发起点有 4 个:Node.js 启动后;setTimeout 回调函数;setInterval 回调函数;也可能是一次 I/O 后的回调函数。无限循环有没有终点当所有的微任务和宏任务都清空的时候,虽然当前没有任务可执行了,但是也并不能代表循环结束了。因为可能存在当前还未回调的异步 I/O,所以这个循环是没有终点的,只要进程在,并且有新的任务存在,就会去执行Node.js 是单线程的还是多线程的?主线程是单线程执行的,但是 Node.js 存在多线程执行,多线程包括 setTimeout 和异步 I/O 事件。其实 Node.js 还存在其他的线程,包括垃圾回收、内存优化等EventLoop 对渲染的影响想必你之前在业务开发中也遇到过 requestIdlecallback 和 requestAnimationFrame,这两个函数在我们之前的内容中没有讲过,但是当你开始考虑它们在 Eventloop 的生命周期的哪一步触发,或者这两个方法的回调会在微任务队列还是宏任务队列执行的时候,才发现好像没有想象中那么简单。这两个方法其实也并不属于 JS 的原生方法,而是浏览器宿主环境提供的方法,因为它们牵扯到另一个问题:渲染。我们知道浏览器作为一个复杂的应用是多线程工作的,除了运行 JS 的线程外,还有渲染线程、定时器触发线程、HTTP 请求线程,等等。JS 线程可以读取并且修改 DOM,而渲染线程也需要读取 DOM,这是一个典型的多线程竞争临界资源的问题。所以浏览器就把这两个线程设计成互斥的,即同时只能有一个线程在执行渲染原本就不应该出现在 Eventloop 相关的知识体系里,但是因为 Eventloop 显然是在讨论 JS 如何运行的问题,而渲染则是浏览器另外一个线程的工作。但是 requestAnimationFrame的出现却把这两件事情给关联起来通过调用 requestAnimationFrame 我们可以在下次渲染之前执行回调函数。那下次渲染具体是哪个时间点呢?渲染和 Eventloop 有什么关系呢? 简单来说,就是在每一次 Eventloop 的末尾,判断当前页面是否处于渲染时机,就是重新渲染有屏幕的硬件限制,比如 60Hz 刷新率,简而言之就是 1 秒刷新了 60 次,16.6ms 刷新一次。这个时候浏览器的渲染间隔时间就没必要小于 16.6ms,因为就算渲染了屏幕上也看不到。当然浏览器也不能保证一定会每 16.6ms 会渲染一次,因为还会受到处理器的性能、JavaScript 执行效率等其他因素影响。回到 requestAnimationFrame,这个 API 保证在下次浏览器渲染之前一定会被调用,实际上我们完全可以把它看成是一个高级版的 setInterval。它们都是在一段时间后执行回调,但是前者的间隔时间是由浏览器自己不断调整的,而后者只能由用户指定。这样的特性也决定了 requestAnimationFrame 更适合用来做针对每一帧来修改的动画效果当然 requestAnimationFrame 不是 Eventloop 里的宏任务,或者说它并不在 Eventloop 的生命周期里,只是浏览器又开放的一个在渲染之前发生的新的 hook。另外需要注意的是微任务的认知概念也需要更新,在执行 animation callback 时也有可能产生微任务(比如 promise 的 callback),会放到 animation queue 处理完后再执行。所以微任务并不是像之前说的那样在每一轮 Eventloop 后处理,而是在 JS 的函数调用栈清空后处理但是 requestIdlecallback 却是一个更好理解的概念。当宏任务队列中没有任务可以处理时,浏览器可能存在“空闲状态”。这段空闲时间可以被 requestIdlecallback 利用起来执行一些优先级不高、不必立即执行的任务,如下图所示:
0
0
0
浏览量461
前端码农

浏览器渲染原理-模拟请求->渲染流程

请求报文格式起始行:[方法][空格][请求URL][HTTP版本][换行符]首部: [首部名称][:][空格][首部内容][换行符]首部结束:[换行符]实体响应报文格式起始行:[HTTP版本][空格][状态码][空格][原因短语][换行符]首部:[首部名称][:][空格][首部内容][换行符]首部结束: [换行符]实体1.基于TCP发送HTTP请求const net = require('net') class HTTPRequest { constructor(options) { this.method = options.method || 'GET'; this.host = options.host || '127.0.0.1'; this.port = options.port || 80; this.path = options.path || '/'; this.headers = options.headers || {} } send(body) { return new Promise((resolve, reject) => { body = Object.keys(body).map(key => (`${key}=${encodeURIComponent(body[key])}`)).join('&'); if (body) { this.headers['Content-Length'] = body.length; }; const socket = net.createConnection({ host:this.host, port:this.port },()=>{ const rows = []; rows.push(`${this.method} ${this.path} HTTP/1.1`); Object.keys(this.headers).forEach(key=>{ rows.push(`${key}: ${this.headers[key]}`); }); let request = rows.join('\r\n') + '\r\n\r\n' + body; socket.write(request) }); socket.on('data',function(data){ // data 为发送请求后返回的结果 }) }) } } async function request() { const request = new HTTPRequest({ method: 'POST', host: '127.0.0.1', port: 3000, path: '/', headers: { name: 'zhufeng', age: 11 } }); let { responseLine, headers, body } = await request.send({ address: '北京' }); } request(); 2.解析响应结果const parser = new HTTPParser() socket.on('data',function(data){ // data 为发送请求后返回的结果 parser.parse(data); if(parser.result){ resolve(parser.result) } }); 3.解析HTMLlet stack = [{ type: 'document', children: [] }]; const parser = new htmlparser2.Parser({ onopentag(name, attributes) { let parent = stack[stack.length - 1]; let element = { tagName: name, type: 'element', children: [], attributes, parent } parent.children.push(element); element.parent = parent; stack.push(element); }, ontext(text) { let parent = stack[stack.length - 1]; let textNode = { type: 'text', text } parent.children.push(textNode) }, onclosetag(tagname) { stack.pop(); } }); parser.end(body) 4.解析CSSconst cssRules = []; const css = require('css'); function parserCss(text) { const ast = css.parse(text); cssRules.push(...ast.stylesheet.rules); } const parser = new htmlparser2.Parser({ onclosetag(tagname) { let parent = stack[stack.length - 1]; if (tagname == 'style') { parserCss(parent.children[0].text); } stack.pop(); } }); 5.计算样式function computedCss(element) { let attrs = element.attributes; // 获取元素属性 element.computedStyle = {}; // 计算样式 Object.entries(attrs).forEach(([key, value]) => { cssRules.forEach(rule => { let selector = rule.selectors[0]; if ((selector == '#'+value && key == 'id') || (selector == '.'+value && key == 'class')) { rule.declarations.forEach(({ property, value }) => { element.computedStyle[property] = value; }) } }) }); } 6.布局绘制function layout(element) { // 计算位置 -> 绘制 if (Object.keys(element.computedStyle).length != 0) { let { background, width, height, top, left } = element.computedStyle let code = ` let canvas = document.getElementById('canvas'); canvas.width = window.innerWidth; canvas.height = window.innerHeight ; let context = canvas.getContext("2d") context.fillStyle = "${background}"; context.fillRect(${top}, ${left}, ${parseInt(width)}, ${parseInt(height)}); ` fs.writeFileSync('./code.js', code); } } 总结:DOM如何生成的当服务端返回的类型是text/html时,浏览器会将收到的数据通过HTMLParser进行解析 (边下载边解析)在解析前会执行预解析操作,会预先加载JS、CSS等文件字节流 -> 分词器 -> Tokens -> 根据token生成节点 -> 插入到 DOM树中遇到js:在解析过程中遇到script标签,HTMLParser会停止解析,(下载)执行对应的脚本。在js执行前,需要等待当前脚本之上的所有CSS加载解析完毕(js是依赖css的加载)CSS样式文件尽量放在页面头部,CSS加载不会阻塞DOM tree解析,浏览器会用解析出的DOM TREE和 CSSOM 进行渲染,不会出现闪烁问题。如果CSS放在底部,浏览是边解析边渲染,渲染出的结果不包含样式,后续会发生重绘操作。JS文件放在HTML底部,防止JS的加载、解析、执行堵塞页面后续的正常渲染
0
0
0
浏览量2014
前端码农

浏览器进程与线程

打开一个页面,为什么有4个进程?Chrome打开一个页面需要启动多少进程?点击Chrome浏览器右上角的“选项”菜单,选择“更多工具”子菜单,点击“任务管理器”,这将打开Chrome的任务管理器的窗口,如下图:可以看到浏览器至少打开了四个进程,1个网络进程、1个浏览器进程、1个GPU进程以及1个渲染进程。在了解这个问题之前,先看看另一个知识点--并行处理。什么是并行处理?计算机中的并行处理就是同一时刻处理多个任务,比如要计算下面这三个表达式的值,并显示出结果。A = 1+2 B = 20/5 C = 7*8 在编写代码的时候,可以把这个过程拆分为四个任务:任务1 是计算A=1+2;任务2 是计算B=20/5;任务3 是计算C=7*8;任务4 是显示最后计算的结果。正常情况下程序可以使用单线程来处理,也就是分四步按照顺序分别执行这四个任务。如果采用多线程,则只需分“两步走”:第一步,使用三个线程同时执行前三个任务;第二步,再执行第四个显示任务。通过对比分析,会发现用单线程执行需要四步,而使用多线程只需要两步。因此,使用并行处理能大大提升性能。线程 VS 进程多线程可以并行处理任务,但是线程是不能单独存在的,它是由进程来启动和管理的。那什么又是进程呢?一个进程就是一个程序的运行实例。详细解释就是,启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,这样的一个运行环境叫进程。以下两张图演示了单线程和多线程处理上面所举例子中的问题:从图中可以看到,线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。总结来说,进程和线程之间的关系有以下4个特点。1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。模拟以下场景:A = 1+2 B = 20/0 C = 7*8从上图可以看出,线程1、线程2、线程3分别把执行的结果写入A、B、C中,然后线程2继续从A、B、C中读取数据,用来显示执行结果。 3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。 当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。 比如之前的IE浏览器,支持很多插件,而这些插件很容易导致内存泄漏,这意味着只要浏览器开着,内存占用就有可能会越来越多,但是当关闭浏览器进程时,这些内存就都会被系统回收掉。 4. 进程之间的内容相互隔离。 进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程A写入数据到进程B的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。 早期浏览器是单进程,即所有功能模块都运行在同一个进程里,因此会导致很多问题:不稳定,不流畅也不安全,如某一程序出错,就会导致整个进程崩溃,以致于整浏览器崩溃 目前多进程架构 这是最新的Chrome进程架构图 作者:自驱 链接:https://juejin.cn/post/7055496725963735071 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。把上述三个表达式稍作修改,在计算B的值的时候,把表达式的分母改成0,当线程执行到B = 20/0时,由于分母为0,线程会执行出错,这样就会导致整个进程的崩溃,当然另外两个线程执行的结果也没有了。2. 线程之间共享进程中的数据。如下图所示,线程之间可以对进程的公共数据进行读写操作。从上图可以看出,线程1、线程2、线程3分别把执行的结果写入A、B、C中,然后线程2继续从A、B、C中读取数据,用来显示执行结果。3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。当一个进程退出时,操作系统会回收该进程所申请的所有资源;即使其中任意线程因为操作不当导致内存泄漏,当进程退出时,这些内存也会被正确回收。比如之前的IE浏览器,支持很多插件,而这些插件很容易导致内存泄漏,这意味着只要浏览器开着,内存占用就有可能会越来越多,但是当关闭浏览器进程时,这些内存就都会被系统回收掉。4. 进程之间的内容相互隔离。进程隔离是为保护操作系统中进程互不干扰的技术,每一个进程只能访问自己占有的数据,也就避免出现进程A写入数据到进程B的情况。正是因为进程之间的数据是严格隔离的,所以一个进程如果崩溃了,或者挂起了,是不会影响到其他进程的。如果进程之间需要进行数据的通信,这时候,就需要使用用于进程间通信(IPC)的机制了。早期浏览器是单进程,即所有功能模块都运行在同一个进程里,因此会导致很多问题:不稳定,不流畅也不安全,如某一程序出错,就会导致整个进程崩溃,以致于整浏览器崩溃目前多进程架构这是最新的Chrome进程架构图从图中可以看出,最新的Chrome浏览器包括:1个浏览器(Browser)主进程、1个 GPU 进程、1个网络(NetWork)进程、多个渲染进程和多个插件进程。浏览器进程。主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。渲染进程。核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。GPU进程。其实,Chrome刚开始发布的时候是没有GPU进程的。而GPU的使用初衷是为了实现3D CSS的效果,只是随后网页、Chrome的UI界面都选择采用GPU来绘制,这使得GPU成为浏览器普遍的需求。最后,Chrome在其多进程架构上也引入了GPU进程。网络进程。主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。不过凡事都有两面性,虽然多进程模型提升了浏览器的稳定性、流畅性和安全性,但同样不可避免地带来了一些问题:更高的资源占用。因为每个进程都会包含公共基础结构的副本(如JavaScript运行环境),这就意味着浏览器会消耗更多的内存资源。更复杂的体系架构。浏览器各模块之间耦合性高、扩展性差等问题,会导致现在的架构已经很难适应新的需求了
0
0
0
浏览量2014
前端码农

JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化

浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第三篇JS的内存机制JS是弱类型、动态语言:弱类型:不需要告诉JavaScript引擎这个或那个变量是什么数据类型,JavaScript引擎在运行代码的时候自己会计算出来。动态:可以使用同一个变量保存不同类型的数据。JS数据类型分类:7种基本数据类型(number,string,undefined,null,boolean,bigInt,symbol)和一种引用数据类型(object,包括:Object, Function, Array ...)。基本数据类型储存在栈空间中,引用数据类型储存在堆空间中,引用数据类型的指针储存在栈空间。因为JavaScript引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。比如一个函数执行结束了,JavaScript引擎需要离开当前的执行上下文,只需要将指针下移到上个执行上下文的地址就可以了,当前执行完的函数执行上下文栈区空间全部回收。由于栈空间设计比较小,容易栈溢出,用于存放基本数据类型的小数据,堆空间很大,就可以存储很多大数据。原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。(深浅拷贝)V8引擎垃圾回收机制数据被使用之后,可能就不再需要了,这种数据称为垃圾数据。如果这些垃圾数据一直保存在内存中,那么内存会越用越多,所以需要对这些垃圾数据进行回收,以释放有限的内存空间。如果某块数据已经不需要了,却依旧保留在内存中,这种情况称为内存泄漏。JavaScript是一门自动垃圾回收的语言,产生的垃圾数据是由垃圾回收器来释放,并不需要手动通过代码来释放。代际假说:大部分对象在内存中存在的时间很短,即很多对象一经分配,很快便变得不可访问。不死的对象,会活的更久V8引擎的垃圾回收策略就是建立在代际假说的基础上的。V8会把堆分为分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。对于这两块区域,V8分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收:副垃圾回收器,主要负责新生代的垃圾回收。主垃圾回收器,主要负责老生代的垃圾回收。不论什么类型的垃圾回收器,都有一套共同的执行流程:第一步是标记空间中活动对象和非活动对象。所谓活动对象就是还在使用的对象,非活动对象就是可以进行垃圾回收的对象。第二步是回收非活动对象所占据的内存。其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如副垃圾回收器。副垃圾回收器原理:副垃圾回收器主要负责新生区的垃圾回收。通常情况下,大多数小的对象都会被分配到新生区,所以这个区域虽然不大,但是垃圾回收还是比较频繁的。新生代中用Scavenge算法来处理。所谓Scavenge算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。由于新生代中采用的Scavenge算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。主垃圾回收器原理:主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。由于老生区的对象比较大,若要在老生区中使用Scavenge算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因而,主垃圾回收器是采用标记-清除(Mark-Sweep) 的算法进行垃圾回收的。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。标记完成后直接将垃圾数据清除达到垃圾回收的目的。标记过程和清除过程就是标记-清除算法,不过对一块内存多次执行标记-清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记-整理(Mark-Compact) ,这个标记过程仍然与标记-清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。V8是如何执行JS代码的JavaScript属于解释型语言,即在每次运行时都需要通过解释器对程序进行动态解释和执行。与之对应的还有编译型语言,在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果。V8执行js过程:生成抽象语法树(AST)和执行上下文:高级语言是开发者可以理解的语言,对于编译器或者解释器来说,它们可以理解的是AST。所以无论解释型语言还是编译型语言,在编译过程中,都会生成一个AST。(补充:AST是非常重要的一种数据结构,在很多项目中有着广泛的应用。其中最著名的一个项目是Babel。Babel是一个被广泛使用的代码转码器,可以将ES6代码转为ES5代码,这意味着可以直接用ES6编写程序,而不用担心现有环境是否支持ES6。Babel的工作原理就是先将ES6源码转换为AST,然后再将ES6语法的AST转换为ES5语法的AST,最后利用ES5的AST生成JavaScript源代码。除了Babel外,还有ESLint也使用AST。ESLint是一个用来检查JavaScript编写规范的插件,其检测流程也是需要将源码转换为AST,然后再利用AST来检查代码规范化的问题。)生成AST需要两个过程: 第一阶段是分词,又称为词法分析,其作用是将一行行的源码拆解成一个个token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。 第二阶段是解析,又称为语法分析,其作用是将上一步生成的token数据,根据语法规则转为AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。生成字节码:有了AST和执行上下文后,解释器Ignition就可以根据AST生成字节码,并解释执行字节码。(字节码是介于AST和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。 )执行代码:生成字节码之后,就进入执行阶段了。通常,如果有一段第一次执行的字节码,解释器Ignition会逐条解释执行。解释器Ignition除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在Ignition执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器TurboFan就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。 字节码配合解释器和编译器,这种技术称为即时编译(JIT)JavaScript性能优化提升单次脚本的执行速度,避免JavaScript的长任务霸占主线程,这样可以使得页面快速响应交互;避免大的内联脚本,因为在解析HTML的过程中,解析和编译也会占用主线程;减少JavaScript文件的容量,因为更小的文件会提升下载速度,并且占用更低的内存。
0
0
0
浏览量2014
前端码农

vue 项目中的性能分析及性能优化

本文先从 Vue 项目在整体上的执行流程谈起,然后详细介绍性能优化的两个重要方面:网络请求优化和代码效率优化。不过要明确一点,那就是在性能优化之外,用户体验才是性能优化的目的用户输入 URL 到页面显示的过程简单来说,就是用户在输入 URL 并且敲击回车之后,浏览器会去查询当前域名对应的 IP 地址。对于 IP 地址来说,它就相当于域名后面的服务器在互联网世界的门牌号。然后,浏览器会向服务器发起一个网络请求,服务器会把浏览器请求的 HTML 代码返回给浏览器。之后,浏览器会解析这段 HTML 代码,并且加载 HTML 代码中需要加载的 CSS 和 JavaScript,然后开始执行 JavaScript 代码。进入到项目的代码逻辑中,可以看到 Vue 中通过 vue-router 计算出当前路由匹配的组件,并且把这些组件显示到页面中,这样页面就完全显示出来了。而性能优化的主要目的,就是让页面显示过程的时间再缩短一些。网络请求优化对于前端来说,可以优化的点,首先就是在首页的标签中,使用标签去通知浏览器对页面中出现的其他域名去做 DNS 的预解析,比如页面中的图片通常都是放置在独立的 CDN 域名下,这样页面加载首页的时候就能预先解析域名并把结果缓存起来 。以淘宝网的首页为例进行分析,可以在淘宝的首页源码中看到下图所示的一列 dns-prefetch 标签,这样首页再出现 img.alicdn.com 这个域名请求的时候,浏览器就可以从缓存中直接获取对应的 IP 地址。项目在整体流程中,会通过 HTTP 请求加载很多的 CSS、JavaScript,以及图片等静态资源。为了让这些文件在网络加载中更快,可以从后面这几方面入手进行优化。首先,浏览器在获取网络文件时,需要通过 HTTP 请求,HTTP 协议底层的 TCP 协议每次创建链接的时候,都需要三次握手,而三次握手会造成额外的网络损耗。如果浏览器需要获取的文件较多,那就会因为三次握手次数过多,而带来过多网络损耗的问题。所以,首先需要的是让文件尽可能地少,这就诞生出一些常见的优化策略,比如先给文件打包,之后再上线;使用 CSS 雪碧图来进行图片打包等等。文件打包这条策略在 HTTP2 全面普及之前还是有效的,但是在 HTTP2 普及之后,多路复用可以优化三次握手带来的网络损耗。其次,除了让文件尽可能少,还可以想办法让这些文件尽可能地小一些,因为如果能减少文件的体积,那文件的加载速度自然也就会变快。这一环节也诞生出一些性能优化策略,比如 CSS 和 JavaScript 代码会在上线之前进行压缩;在图片格式的选择上,对于大部分图片来说,需要使用 JPG 格式,精细度要求高的图片才使用 PNG 格式;优先使用 WebP 等等。也就是说,尽可能在同等像素下,选择体积更小的图片格式。在性能优化中,懒加载的方式也被广泛使用。图片懒加载的意思是:可以动态计算图片的位置,只需要正常加载首屏出现的图片,其他暂时没出现的图片只显示一个占位符,等到页面滚动到对应图片位置的时候,再去加载完整图片。除了图片,项目中也会做路由懒加载,现在项目打包后,所有路由的代码都在首页一起加载。但也可以把不常用的路由单独打包,在用户访问到这个路由的时候再去加载代码。下面的代码中,vue-router 也提供了懒加载的使用方式,只有用户访问了 /course/:id 这个页面后,对应页面的代码才会加载执行。 { path: '/course/:id', component: () => import('../pages/courseInfo'), }, 在文件大小的问题上,Lighthouse 已经给了比较详细的优化方法,比如控制图片大小、减少冗余代码等等,可以在项目打包的时候,使用可视化的插件来查看包大小的分布。到项目根目录下,通过执行 npm install 操作来安装插件 rollup-plugin-visualizer。使用这个插件后,就可以获取到代码文件大小的报告了。之后,进入到 vite.config.js 这个文件中,新增下列代码,就可以在 Vite 中加载可视化分析插件。 import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [vue(),vueJsx(), visualizer()], }) 然后,在项目的根目录下执行 npm run build 命令后,项目就把项目代码打包在根目录的 dist 目录下,并且根目录下多了一个文件 stat.html。用浏览器打开这个 stat 文件,就能看到下面的示意图。项目中的 ECharts 和 Element3 的体积远远大于项目代码的体积,这时候就需要用懒加载和按需加载的方式,去优化项目整体的体积。那么这些文件如何才能高效复用呢?我们需要做的,就是尽可能高效地利用浏览器的缓存机制,在文件内容没有发生变化的时候,做到一次加载多次使用,项目中如果成功复用一个几百 KB 的文件,对于性能优化来说是一个巨大的提升。浏览器的缓存机制有好几个 Headers 可以实现,Expires、Cache-control,last-modify、etag 这些缓存相关的 Header 可以让浏览器高效地利用文件缓存。我们需要做的是,只有当文件的内容修改了,才会重新加载文件。这也是为什么项目执行 npm run build 命令之后,静态资源都会带上一串 Hash 值,因为这样确保了只有文件内容发生变化的时候,文件名才会发生变化,其他情况都会复用缓存。代码效率优化在浏览器加载网络请求结束后,页面开始执行 JavaScript,因为 Vue 已经对项目做了很多内部的优化,所以在代码层面,我们需要做的优化并不多。很多 Vue 2 中的性能优化策略,在 Vue 3 时代已经不需要了,我们需要做的就是遵循 Vue 官方的最佳实践,其余的交给 Vue 自身来优化就可以了。比如 computed 内置有缓存机制,比使用 watch 函数好一些;组件里也优先使用 template 去激活 Vue 内置的静态标记,也就是能够对代码执行效率进行优化;v-for 循环渲染一定要有 key,从而能够在虚拟 DOM 计算 Diff 的时候更高效复用标签等等。然后就是 JavaScript 本身的性能优化,或者说某些实现场景算法的选择了,这里需要具体问题具体分析,在通过性能监测工具发现代码运行的瓶颈后,依次对耗时过长的函数进行优化即可。比如下面的代码,实现了一个斐波那契数列,也就是说,在实现的这个数列中,每一个数的值是前面两个数的值之和。可以使用简单的递归算法实现斐波那契数列后,在页面显示计算结果。 function fib(n){ if(n<=1) return 1 return fib(n-1)+fib(n-2) } let count = ref(fib(38)) 上面的代码在功能上,虽然实现了斐波那契数列的要求,但是我们能够感觉到页面有些卡顿,所以可以来对页面的性能做一下检测。打开调试窗口中的 Performance 面板,使用录制功能后,便可得到下面的火焰图。通过这个火焰图,可以清晰地定位出这个项目中,整体而言耗时最长的 fib 函数,并且能看到这个函数被递归执行了无数次。到这里,不难意识到这段代码有性能问题。不过,定位到问题出现的地方之后,代码性能的优化就变得方向明确了。下面的代码中,使用递推的方式优化了斐波那契数列的计算过程,页面也变得流畅起来,这样优化就算完成了。其实对于斐波那契数列的计算而言,得到最好性能的方式是使用数学公式 + 矩阵来计算。不过在项目瓶颈到来之前,采用下面的算法已经足够了,这也是性能优化另外一个重要原则,那就是不要过度优化。 function fib(n){ let arr = [1,1] let i = 2 while(i<=n){ arr[i] = arr[i-1]+arr[i-2] i++ } return arr[n] } 用户体验优化性能优化的主要目的,还是为了能让用户在浏览网页的时候感觉更舒服,所以有些场景不能只考虑单纯的性能指标,还要结合用户的交互体验进行设计,必要的时候,可以损失一些性能去换取交互体验的提升。比如用户加载大量图片的同时,如果本身图片清晰度较高,那直接加载的话,页面会有很多图一直是白框。所以可以预先解析出图片的一个模糊版本,加载图片的时候,先加载这个模糊的图作为占位符,然后再去加载清晰的版本。虽然额外加载了图片文件,但是用户在体验上得到了提升。类似的场景还有很多,比如用户上传文件的时候,如果文件过大,那么上传可能就会很耗时。而且一旦上传的过程中发生了网络中断,那上传就前功尽弃了。为了提高用户的体验,可以选择断点续传,也就是把文件切分成小块后,挨个上传。这样即使中间上传中断,但下次再上传时,只上传缺失的那些部分就可以了。可以看到,断点上传虽然在性能上,会造成网络请求变多的问题,但也极大地提高了用户上传的体验。还有很多组件库也会提供骨架图的组件,能够在页面还没有解析完成之前,先渲染一个页面的骨架和 loading 的状态,这样用户在页面加载的等待期就不至于一直白屏性能监测报告解释一下 FCP、TTI 和 LCP 这几个关键指标的含义。首先是 First Contentful Paint,通常简写为 FCP,它表示的是页面上呈现第一个 DOM 元素的时间。在此之前,页面都是白屏的状态;然后是 Time to interactive,通常简写为 TTI,也就是页面可以开始交互的时间;还有和用户体验相关的 Largest Contentful Paint,通常简写为 LCP,这是页面视口上最大的图片或者文本块渲染的时间,在这个时间,用户能看到渲染基本完成后的首页,这也是用户体验里非常重要的一个指标。可以通过代码中的 performance 对象去动态获取性能指标数据,并且统一发送给后端,实现网页性能的监控。性能监控也是大型项目必备的监控系统之一,可以获取到用户电脑上项目运行的状态。下图展示了 performance 中所有的性能指标,可以通过这些指标计算出需要统计的性能结果。const timing = window.performance && window.performance.timing const navigation = window.performance && window.performance.navigation // DNS 解析: const dns = timing.domainLookupEnd - timing.domainLookupStart // 总体网络交互耗时: const network = timing.responseEnd - timing.navigationStart // 渲染处理: const processing = (timing.domComplete || timing.domLoading) - timing.domLoading // 可交互: const active = timing.domInteractive - timing.navigationStart 在上面的代码中,通过 Performance API 获取了 DNS 解析、网络、渲染和可交互的时间消耗。有了这些指标后,就可以随时对用户端的性能进行检测,做到提前发现问题,提高项目的稳定性。
0
0
0
浏览量2016
前端码农

没用的东西,你连个内存泄漏都排查不出来!!

背景 (书接上回)ui妹子的无理要求,我通通满足了。但是不出意外的话,意外就出来了。此功能在上线之后,我们的业务在客户app内使用刷脸的时候会因为内存过高导致app将webview杀死。然后我们leader爆了,让我排查问题。可是可是,我哪里会排查内存泄漏呀。我:我不会。你自己不会上吗?你tm天天端个茶,抽个烟,翘个二郎腿,色眯眯的看着ui妹妹。领导:污蔑,你纯粹就是污蔑。我tm现在就可以让你滚蛋,你信吗?我:我怕你个鸟哦,我还不知道你啥水平,你tm能写出来个防抖节流,我就给你磕头。领导:hi~ui妹妹,今天过的好吗,来,哥哥这里有茶喝。(此时ui妹妹路过)。你赶快给我干活,以后ui妹妹留给你。艹!你早这么说不就好了。开始学习Chrome devTools查看内存情况打开Chrome的无痕模式,这样做的目的是为了屏蔽掉Chrome插件对我们之后测试内存占用情况的影响 打开开发者工具,找到Performance这一栏,可以看到其内部带着一些功能按钮,例如:开始录制按钮;刷新页面按钮;清空记录按钮;记录并可视化js内存、节点、事件监听器按钮;触发垃圾回收机制按钮等简单录制一下百度页面,看看我们能获得什么,如下动图所示:从上图中我们可以看到,在页面从零到加载完成这个过程中JS Heap(js堆内存)、documents(文档)、Nodes(DOM节点)、Listeners(监听器)、GPU memory(GPU内存)的最低值、最高值以及随时间的走势曲线,这也是我们主要关注的点看看开发者工具中的Memory一栏,其主要是用于记录页面堆内存的具体情况以及js堆内存随加载时间线动态的分配情况堆快照就像照相机一样,能记录你当前页面的堆内存情况,每快照一次就会产生一条快照记录如上图所示,刚开始执行了一次快照,记录了当时堆内存空间占用为33.7MB,然后我们点击了页面中某些按钮,又执行一次快照,记录了当时堆内存空间占用为32.5MB。并且点击对应的快照记录,能看到当时所有内存中的变量情况(结构、占总占用内存的百分比...)在开始记录后,我们可以看到图中右上角有起伏的蓝色与灰色的柱形图,其中蓝色表示当前时间线下占用着的内存;灰色表示之前占用的内存空间已被清除释放在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:内存泄漏的场景闭包使用不当引起内存泄漏全局变量分离的DOM节点控制台的打印遗忘的定时器1. 闭包使用不当引起内存泄漏使用Performance和Memory来查看一下闭包导致的内存泄漏问题<button onclick="myClick()">执行fn1函数</button> <script> function fn1 () { let a = new Array(10000) // 这里设置了一个很大的数组对象 let b = 3 function fn2() { let c = [1, 2, 3] } fn2() return a } let res = [] function myClick() { res.push(fn1()) } </script>在退出fn1函数执行上下文后,该上下文中的变量a本应被当作垃圾数据给回收掉,但因fn1函数最终将变量a返回并赋值给全局变量res,其产生了对变量a的引用,所以变量a被标记为活动变量并一直占用着相应的内存,假设变量res后续用不到,这就算是一种闭包使用不当的例子设置了一个按钮,每次执行就会将fn1函数的返回值添加到全局数组变量res中,是为了能在performacne的曲线图中看出效果,如图所示:在每次录制开始时手动触发一次垃圾回收机制,这是为了确认一个初始的堆内存基准线,便于后面的对比,然后我们点击了几次按钮,即往全局数组变量res中添加了几个比较大的数组对象,最后再触发一次垃圾回收,发现录制结果的JS Heap曲线刚开始成阶梯式上升的,最后的曲线的高度比基准线要高,说明可能是存在内存泄漏的问题在得知有内存泄漏的情况存在时,我们可以改用Memory来更明确得确认问题和定位问题首先可以用Allocation instrumentation on timeline来确认问题,如下图所示:在我们每次点击按钮后,动态内存分配情况图上都会出现一个蓝色的柱形,并且在我们触发垃圾回收后,蓝色柱形都没变成灰色柱形,即之前分配的内存并未被清除所以此时我们就可以更明确得确认内存泄漏的问题是存在的了,接下来就精准定位问题,可以利用Heap snapshot来定位问题,如图所示:第一次先点击快照记录初始的内存情况,然后我们多次点击按钮后再次点击快照,记录此时的内存情况,发现从原来的1.1M内存空间变成了1.4M内存空间,然后我们选中第二条快照记录,可以看到右上角有个All objects的字段,其表示展示的是当前选中的快照记录所有对象的分配情况,而我们想要知道的是第二条快照与第一条快照的区别在哪,所以选择Object allocated between Snapshot1 and Snapshot2即展示第一条快照和第二条快照存在差异的内存对象分配情况,此时可以看到Array的百分比很高,初步可以判断是该变量存在问题,点击查看详情后就能查看到该变量对应的具体数据了以上就是一个判断闭包带来内存泄漏问题并简单定位的方法了2. 全局变量全局的变量一般是不会被垃圾回收掉的当然这并不是说变量都不能存在全局,只是有时候会因为疏忽而导致某些变量流失到全局,例如未声明变量,却直接对某变量进行赋值,就会导致该变量在全局创建,如下所示:function fn1() { // 此处变量name未被声明 name = new Array(99999999) } fn1()此时这种情况就会在全局自动创建一个变量name,并将一个很大的数组赋值给name,又因为是全局变量,所以该内存空间就一直不会被释放解决办法的话,自己平时要多加注意,不要在变量未声明前赋值,或者也可以开启严格模式,这样就会在不知情犯错时,收到报错警告,例如function fn1() { 'use strict'; name = new Array(99999999) } fn1()3. 分离的DOM节点假设你手动移除了某个dom节点,本应释放该dom节点所占用的内存,但却因为疏忽导致某处代码仍对该被移除节点有引用,最终导致该节点所占内存无法被释放,例如这种情况<div id="root"> <div class="child">我是子元素</div> <button>移除</button> </div> <script> let btn = document.querySelector('button') let child = document.querySelector('.child') let root = document.querySelector('#root') btn.addEventListener('click', function() { root.removeChild(child) }) </script>该代码所做的操作就是点击按钮后移除.child的节点,虽然点击后,该节点确实从dom被移除了,但全局变量child仍对该节点有引用,所以导致该节点的内存一直无法被释放,可以尝试用Memory的快照功能来检测一下,如图所示同样的先记录一下初始状态的快照,然后点击移除按钮后,再点击一次快照,此时内存大小我们看不出什么变化,因为移除的节点占用的内存实在太小了可以忽略不计,但我们可以点击第二条快照记录,在筛选框里输入detached,于是就会展示所有脱离了却又未被清除的节点对象解决办法如下图所示:<div id="root"> <div class="child">我是子元素</div> <button>移除</button> </div> <script> let btn = document.querySelector('button') btn.addEventListener('click', function() { let child = document.querySelector('.child') let root = document.querySelector('#root') root.removeChild(child) }) </script>改动很简单,就是将对.child节点的引用移动到了click事件的回调函数中,那么当移除节点并退出回调函数的执行上文后就会自动清除对该节点的引用,那么自然就不会存在内存泄漏的情况了,我们来验证一下,如下图所示:结果很明显,这样处理过后就不存在内存泄漏的情况了4. 控制台的打印<button>按钮</button> <script> document.querySelector('button').addEventListener('click', function() { let obj = new Array(1000000) console.log(obj); }) </script>我们在按钮的点击回调事件中创建了一个很大的数组对象并打印,用performance来验证一下开始录制,先触发一次垃圾回收清除初始的内存,然后点击三次按钮,即执行了三次点击事件,最后再触发一次垃圾回收。查看录制结果发现JS Heap曲线成阶梯上升,并且最终保持的高度比初始基准线高很多,这说明每次执行点击事件创建的很大的数组对象obj都因为console.log被浏览器保存了下来并且无法被回收接下来注释掉console.log,再来看一下结果:<button>按钮</button> <script> document.querySelector('button').addEventListener('click', function() { let obj = new Array(1000000) // console.log(obj); }) </script>可以看到没有打印以后,每次创建的obj都立马被销毁了,并且最终触发垃圾回收机制后跟初始的基准线同样高,说明已经不存在内存泄漏的现象了其实同理 console.log也可以用Memory来进一步验证未注释 console.logconsole.log最后简单总结一下:在开发环境下,可以使用控制台打印便于调试,但是在生产环境下,尽可能得不要在控制台打印数据。所以我们经常会在代码中看到类似如下的操作:// 如果在开发环境下,打印变量obj if(isDev) { console.log(obj) }这样就避免了生产环境下无用的变量打印占用一定的内存空间,同样的除了console.log之外,console.error、console.info、console.dir等等都不要在生产环境下使用5. 遗忘的定时器定时器也是平时很多人会忽略的一个问题,比如定义了定时器后就再也不去考虑清除定时器了,这样其实也会造成一定的内存泄漏。来看一个代码示例:<button>开启定时器</button> <script> function fn1() { let largeObj = new Array(100000) setInterval(() => { let myObj = largeObj }, 1000) } document.querySelector('button').addEventListener('click', function() { fn1() }) </script>这段代码是在点击按钮后执行fn1函数,fn1函数内创建了一个很大的数组对象largeObj,同时创建了一个setInterval定时器,定时器的回调函数只是简单的引用了一下变量largeObj,我们来看看其整体的内存分配情况吧:fn1函数后会退出该函数的执行上下文,紧跟着函数体内的局部变量应该被清除,但图中performance的录制结果显示似乎是存在内存泄漏问题的,即最终曲线高度比基准线高度要高,那么再用Memory来确认一次:在我们点击按钮后,从动态内存分配的图上看到出现一个蓝色柱形,说明浏览器为变量largeObj分配了一段内存,但是之后这段内存并没有被释放掉,说明的确存在内存泄漏的问题,原因其实就是因为setInterval的回调函数内对变量largeObj有一个引用关系,而定时器一直未被清除,所以变量largeObj的内存也自然不会被释放那么我们如何来解决这个问题呢,假设我们只需要让定时器执行三次就可以了,那么我们可以改动一下代码:<button>开启定时器</button> <script> function fn1() { let largeObj = new Array(100000) let index = 0 let timer = setInterval(() => { if(index === 3) clearInterval(timer); let myObj = largeObj index ++ }, 1000) } document.querySelector('button').addEventListener('click', function() { fn1() }) </script>现在我们再通过performance和memory来看看还不会存在内存泄漏的问题performance这次的录制结果就能看出,最后的曲线高度和初始基准线的高度一样,说明并没有内存泄漏的情况memory这里做一个解释,图中刚开始出现的蓝色柱形是因为我在录制后刷新了页面,可以忽略;然后我们点击了按钮,看到又出现了一个蓝色柱形,此时就是为fn1函数中的变量largeObj分配了内存,3s后该内存又被释放了,即变成了灰色柱形。所以我们可以得出结论,这段代码不存在内存泄漏的问题简单总结一下: 大家在平时用到了定时器,如果在用不到定时器后一定要清除掉,否则就会出现本例中的情况。除了setTimeout和setInterval,其实浏览器还提供了一个API也可能就存在这样的问题,那就是requestAnimationFrame好了好了,学完了,ui妹妹我来了ui妹妹:去你m的,滚远点好了兄弟们,内存泄漏学会了吗?
0
0
0
浏览量108
前端码农

说真的,JS类型转换你真的懂吗?

1. JS内置类型JavaScript 的数据类型有下图所示其中,前 7 种类型为基础类型,最后 1 种(Object)为引用类型,也是你需要重点关注的,因为它在日常工作中是使用得最频繁,也是需要关注最多技术细节的数据类型JavaScript一共有8种数据类型,其中有7种基本数据类型:Undefined、Null、Boolean、Number、String、Symbol(es6新增,表示独一无二的值)和BigInt(es10新增);1种引用数据类型——Object(Object本质上是由一组无序的名值对组成的)。里面包含 function、Array、Date等。JavaScript不支持任何创建自定义类型的机制,而所有值最终都将是上述 8 种数据类型之一。 引用数据类型: 对象Object(包含普通对象-Object,数组对象-Array,正则对象-RegExp,日期对象-Date,数学函数-Math,函数对象-Function)在这里,我想先请你重点了解下面两点,因为各种 JavaScript 的数据类型最后都会在初始化之后放在不同的内存中,因此上面的数据类型大致可以分成两类来进行存储:原始数据类型:基础类型存储在栈内存,被引用或拷贝时,会创建一个完全相等的变量;占据空间小、大小固定,属于被频繁使用数据,所以放入栈中存储。引用数据类型:引用类型存储在堆内存,存储的是地址,多个引用指向同一个地址,这里会涉及一个“共享”的概念;占据空间大、大小不固定。引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。JavaScript 中的数据是如何存储在内存中的?在 JavaScript 中,原始类型的赋值会完整复制变量值,而引用类型的赋值是复制引用地址。在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间、堆空间。其中的代码空间主要是存储可执行代码的,原始类型(Number、String、Null、Undefined、Boolean、Symbol、BigInt)的数据值都是直接保存在“栈”中的,引用类型(Object)的值是存放在“堆”中的。因此在栈空间中(执行上下文),原始类型存储的是变量的值,而引用类型存储的是其在"堆空间"中的地址,当 JavaScript 需要访问该数据的时候,是通过栈中的引用地址来访问的,相当于多了一道转手流程。在编译过程中,如果 JavaScript 引擎判断到一个闭包,也会在堆空间创建换一个“closure(fn)”的对象(这是一个内部对象,JavaScript 是无法访问的),用来保存闭包中的变量。所以闭包中的变量是存储在“堆空间”中的。JavaScript 引擎需要用栈来维护程序执行期间上下文的状态,如果栈空间大了话,所有的数据都存放在栈空间里面,那么会影响到上下文切换的效率,进而又影响到整个程序的执行效率。通常情况下,栈空间都不会设置太大,主要用来存放一些原始类型的小数据。而引用类型的数据占用的空间都比较大,所以这一类数据会被存放到堆中,堆空间很大,能存放很多大的数据,不过缺点是分配内存和回收内存都会占用一定的时间。因此需要“栈”和“堆”两种空间。题目一:初出茅庐let a = { name: 'lee', age: 18 } let b = a; console.log(a.name); //第一个console b.name = 'son'; console.log(a.name); //第二个console console.log(b.name); //第三个console这道题比较简单,我们可以看到第一个 console 打出来 name 是 'lee',这应该没什么疑问;但是在执行了 b.name='son' 之后,结果你会发现 a 和 b 的属性 name 都是 'son',第二个和第三个打印结果是一样的,这里就体现了引用类型的“共享”的特性,即这两个值都存在同一块内存中共享,一个发生了改变,另外一个也随之跟着变化。你可以直接在 Chrome 控制台敲一遍,深入理解一下这部分概念。下面我们再看一段代码,它是比题目一稍复杂一些的对象属性变化问题。题目二:渐入佳境let a = { name: 'Julia', age: 20 } function change(o) { o.age = 24; o = { name: 'Kath', age: 30 } return o; } let b = change(a); // 注意这里没有new,后面new相关会有专门文章讲解 console.log(b.age); // 第一个console console.log(a.age); // 第二个console这道题涉及了 function,你通过上述代码可以看到第一个 console 的结果是 30,b 最后打印结果是 {name: "Kath", age: 30};第二个 console 的返回结果是 24,而 a 最后的打印结果是 {name: "Julia", age: 24}。是不是和你预想的有些区别?你要注意的是,这里的 function 和 return 带来了不一样的东西。原因在于:函数传参进来的 o,传递的是对象在堆中的内存地址值,通过调用 o.age = 24(第 7 行代码)确实改变了 a 对象的 age 属性;但是第 12 行代码的 return 却又把 o 变成了另一个内存地址,将 {name: "Kath", age: 30} 存入其中,最后返回 b 的值就变成了 {name: "Kath", age: 30}。而如果把第 12 行去掉,那么 b 就会返回 undefined2. 数据类型检测(1)typeoftypeof 对于原始类型来说,除了 null 都可以显示正确的类型console.log(typeof 2); // number console.log(typeof true); // boolean console.log(typeof 'str'); // string console.log(typeof []); // object []数组的数据类型在 typeof 中被解释为 object console.log(typeof function(){}); // function console.log(typeof {}); // object console.log(typeof undefined); // undefined console.log(typeof null); // object null 的数据类型被 typeof 解释为 objecttypeof 对于对象来说,除了函数都会显示 object,所以说 typeof 并不能准确判断变量到底是什么类型,所以想判断一个对象的正确类型,这时候可以考虑使用 instanceof(2)instanceofinstanceof 可以正确的判断对象的类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototypeconsole.log(2 instanceof Number); // false console.log(true instanceof Boolean); // false console.log('str' instanceof String); // false console.log([] instanceof Array); // true console.log(function(){} instanceof Function); // true console.log({} instanceof Object); // true // console.log(undefined instanceof Undefined); // console.log(null instanceof Null);instanceof 可以准确地判断复杂引用数据类型,但是不能正确判断基础数据类型;而 typeof 也存在弊端,它虽然可以判断基础数据类型(null 除外),但是引用数据类型中,除了 function 类型以外,其他的也无法判断// 我们也可以试着实现一下 instanceof function _instanceof(left, right) { // 由于instance要检测的是某对象,需要有一个前置判断条件 //基本数据类型直接返回false if(typeof left !== 'object' || left === null) return false; // 获得类型的原型 let prototype = right.prototype // 获得对象的原型 left = left.__proto__ // 判断对象的类型是否等于类型的原型 while (true) { if (left === null) return false if (prototype === left) return true left = left.__proto__ } } console.log('test', _instanceof(null, Array)) // false console.log('test', _instanceof([], Array)) // true console.log('test', _instanceof('', Array)) // false console.log('test', _instanceof({}, Object)) // true(3)constructorconsole.log((2).constructor === Number); // true console.log((true).constructor === Boolean); // true console.log(('str').constructor === String); // true console.log(([]).constructor === Array); // true console.log((function() {}).constructor === Function); // true console.log(({}).constructor === Object); // true这里有一个坑,如果我创建一个对象,更改它的原型,constructor就会变得不可靠了function Fn(){}; Fn.prototype=new Array(); var f=new Fn(); console.log(f.constructor===Fn); // false console.log(f.constructor===Array); // true (4)Object.prototype.toString.call()toString() 是 Object 的原型方法,调用该方法,可以统一返回格式为 “[object Xxx]” 的字符串,其中 Xxx 就是对象的类型。对于 Object 对象,直接调用 toString() 就能返回 [object Object];而对于其他对象,则需要通过 call 来调用,才能返回正确的类型信息。我们来看一下代码。Object.prototype.toString({}) // "[object Object]" Object.prototype.toString.call({}) // 同上结果,加上call也ok Object.prototype.toString.call(1) // "[object Number]" Object.prototype.toString.call('1') // "[object String]" Object.prototype.toString.call(true) // "[object Boolean]" Object.prototype.toString.call(function(){}) // "[object Function]" Object.prototype.toString.call(null) //"[object Null]" Object.prototype.toString.call(undefined) //"[object Undefined]" Object.prototype.toString.call(/123/g) //"[object RegExp]" Object.prototype.toString.call(new Date()) //"[object Date]" Object.prototype.toString.call([]) //"[object Array]" Object.prototype.toString.call(document) //"[object HTMLDocument]" Object.prototype.toString.call(window) //"[object Window]" // 从上面这段代码可以看出,Object.prototype.toString.call() 可以很好地判断引用类型,甚至可以把 document 和 window 都区分开来。实现一个全局通用的数据类型判断方法,来加深你的理解,代码如下function getType(obj){ let type = typeof obj; if (type !== "object") { // 先进行typeof判断,如果是基础数据类型,直接返回 return type; } // 对于typeof返回结果是object的,再进行如下的判断,正则返回结果 return Object.prototype.toString.call(obj).replace(/^[object (\S+)]$/, '$1'); // 注意正则中间有个空格 } /* 代码验证,需要注意大小写,哪些是typeof判断,哪些是toString判断?思考下 */ getType([]) // "Array" typeof []是object,因此toString返回 getType('123') // "string" typeof 直接返回 getType(window) // "Window" toString返回 getType(null) // "Null"首字母大写,typeof null是object,需toString来判断 getType(undefined) // "undefined" typeof 直接返回 getType() // "undefined" typeof 直接返回 getType(function(){}) // "function" typeof能判断,因此首字母小写 getType(/123/g) //"RegExp" toString返回小结typeof 直接在计算机底层基于数据类型的值(二进制)进行检测 typeof null为object 原因是对象存在在计算机中,都是以000开始的二进制存储,所以检测出来的结果是对象 typeof 普通对象/数组对象/正则对象/日期对象 都是object typeof NaN === 'number'instanceof 检测当前实例是否属于这个类的 底层机制:只要当前类出现在实例的原型上,结果都是true 不能检测基本数据类型constructor 支持基本类型 constructor可以随便改,也不准Object.prototype.toString.call([val]) 返回当前实例所属类信息判断 Target 的类型,单单用 typeof 并无法完全满足,这其实并不是 bug,本质原因是 JS 的万物皆对象的理论。因此要真正完美判断时,我们需要区分对待:基本类型(null): 使用 String(null)基本类型(string / number / boolean / undefined) + function: - 直接使用 typeof即可其余引用类型(Array / Date / RegExp Error): 调用toString后根据[object XXX]进行判断3. 数据类型转换我们先看一段代码,了解下大致的情况。'123' == 123 // false or true? '' == null // false or true? '' == 0 // false or true? [] == 0 // false or true? [] == '' // false or true? [] == ![] // false or true? null == undefined // false or true? Number(null) // 返回什么? Number('') // 返回什么? parseInt(''); // 返回什么? {}+10 // 返回什么? let obj = { [Symbol.toPrimitive]() { return 200; }, valueOf() { return 300; }, toString() { return 'Hello'; } } console.log(obj + 200); // 这里打印出来是多少?首先我们要知道,在 JS 中类型转换只有三种情况,分别是:转换为布尔值转换为数字转换为字符串转Boolean在条件判断时,除了 undefined,null, false, NaN, '', 0, -0,其他所有值都转为 true,包括所有对象Boolean(0) //false Boolean(null) //false Boolean(undefined) //false Boolean(NaN) //false Boolean(1) //true Boolean(13) //true Boolean('12') //true对象转原始类型对象在转换类型的时候,会调用内置的 [[ToPrimitive]] 函数,对于该函数来说,算法逻辑一般来说如下如果已经是原始类型了,那就不需要转换了调用 x.valueOf(),如果转换为基础类型,就返回转换的值调用 x.toString(),如果转换为基础类型,就返回转换的值如果都没有返回原始类型,就会报错当然你也可以重写 Symbol.toPrimitive,该方法在转原始类型时调用优先级最高。let a = { valueOf() { return 0 }, toString() { return '1' }, [Symbol.toPrimitive]() { return 2 } } 1 + a // => 3四则运算符它有以下几个特点:运算中其中一方为字符串,那么就会把另一方也转换为字符串如果一方不是字符串或者数字,那么会将它转换为数字或者字符串1 + '1' // '11' true + true // 2 4 + [1,2,3] // "41,2,3"对于第一行代码来说,触发特点一,所以将数字 1 转换为字符串,得到结果 '11'对于第二行代码来说,触发特点二,所以将 true 转为数字 1对于第三行代码来说,触发特点二,所以将数组通过 toString转为字符串 1,2,3,得到结果 41,2,3另外对于加法还需要注意这个表达式 'a' + + 'b''a' + + 'b' // -> "aNaN"因为 + 'b' 等于 NaN,所以结果为 "aNaN",你可能也会在一些代码中看到过 + '1'的形式来快速获取 number 类型。那么对于除了加法的运算符来说,只要其中一方是数字,那么另一方就会被转为数字4 * '3' // 12 4 * [] // 0 4 * [1, 2] // NaN比较运算符如果是对象,就通过 toPrimitive 转换对象如果是字符串,就通过 unicode 字符索引来比较let a = { valueOf() { return 0 }, toString() { return '1' } } a > -1 // true在以上代码中,因为 a 是对象,所以会通过 valueOf 转换为原始类型再比较值。强制类型转换强制类型转换方式包括 Number()、parseInt()、parseFloat()、toString()、String()、Boolean(),这几种方法都比较类似Number() 方法的强制转换规则如果是布尔值,true 和 false 分别被转换为 1 和 0;如果是数字,返回自身;如果是 null,返回 0;如果是 undefined,返回 NaN;如果是字符串,遵循以下规则:如果字符串中只包含数字(或者是 0X / 0x 开头的十六进制数字字符串,允许包含正负号),则将其转换为十进制;如果字符串中包含有效的浮点格式,将其转换为浮点数值;如果是空字符串,将其转换为 0;如果不是以上格式的字符串,均返回 NaN;如果是 Symbol,抛出错误;如果是对象,并且部署了 [Symbol.toPrimitive] ,那么调用此方法,否则调用对象的 valueOf() 方法,然后依据前面的规则转换返回的值;如果转换的结果是 NaN ,则调用对象的 toString() 方法,再次依照前面的顺序转换返回对应的值。Number(true); // 1 Number(false); // 0 Number('0111'); //111 Number(null); //0 Number(''); //0 Number('1a'); //NaN Number(-0X11); //-17 Number('0X11') //17Object 的转换规则对象转换的规则,会先调用内置的 [ToPrimitive] 函数,其规则逻辑如下:如果部署了 Symbol.toPrimitive 方法,优先调用再返回;调用 valueOf(),如果转换为基础类型,则返回;调用 toString(),如果转换为基础类型,则返回;如果都没有返回基础类型,会报错。var obj = { value: 1, valueOf() { return 2; }, toString() { return '3' }, [Symbol.toPrimitive]() { return 4 } } console.log(obj + 1); // 输出5 // 因为有Symbol.toPrimitive,就优先执行这个;如果Symbol.toPrimitive这段代码删掉,则执行valueOf打印结果为3;如果valueOf也去掉,则调用toString返回'31'(字符串拼接) // 再看两个特殊的case: 10 + {} // "10[object Object]",注意:{}会默认调用valueOf是{},不是基础类型继续转换,调用toString,返回结果"[object Object]",于是和10进行'+'运算,按照字符串拼接规则来,参考'+'的规则C [1,2,undefined,4,5] + 10 // "1,2,,4,510",注意[1,2,undefined,4,5]会默认先调用valueOf结果还是这个数组,不是基础数据类型继续转换,也还是调用toString,返回"1,2,,4,5",然后再和10进行运算,还是按照字符串拼接规则,参考'+'的第3条规则'==' 的隐式类型转换规则如果类型相同,无须进行类型转换;如果其中一个操作值是 null 或者 undefined,那么另一个操作符必须为 null 或者 undefined,才会返回 true,否则都返回 false;如果其中一个是 Symbol 类型,那么返回 false;两个操作值如果为 string 和 number 类型,那么就会将字符串转换为 number;如果一个操作值是 boolean,那么转换成 number;如果一个操作值为 object 且另一方为 string、number 或者 symbol,就会把 object 转为原始类型再进行判断(调用 object 的 valueOf/toString 方法进行转换)。null == undefined // true 规则2 null == 0 // false 规则2 '' == null // false 规则2 '' == 0 // true 规则4 字符串转隐式转换成Number之后再对比 '123' == 123 // true 规则4 字符串转隐式转换成Number之后再对比 0 == false // true e规则 布尔型隐式转换成Number之后再对比 1 == true // true e规则 布尔型隐式转换成Number之后再对比 var a = { value: 0, valueOf: function() { this.value++; return this.value; } }; // 注意这里a又可以等于1、2、3 console.log(a == 1 && a == 2 && a ==3); //true f规则 Object隐式转换 // 注:但是执行过3遍之后,再重新执行a==3或之前的数字就是false,因为value已经加上去了,这里需要注意一下'+' 的隐式类型转换规则'+' 号操作符,不仅可以用作数字相加,还可以用作字符串拼接。仅当 '+' 号两边都是数字时,进行的是加法运算;如果两边都是字符串,则直接拼接,无须进行隐式类型转换。如果其中有一个是字符串,另外一个是 undefined、null 或布尔型,则调用 toString() 方法进行字符串拼接;如果是纯对象、数组、正则等,则默认调用对象的转换方法会存在优先级,然后再进行拼接。如果其中有一个是数字,另外一个是 undefined、null、布尔型或数字,则会将其转换成数字进行加法运算,对象的情况还是参考上一条规则。如果其中一个是字符串、一个是数字,则按照字符串规则进行拼接1 + 2 // 3 常规情况 '1' + '2' // '12' 常规情况 // 下面看一下特殊情况 '1' + undefined // "1undefined" 规则1,undefined转换字符串 '1' + null // "1null" 规则1,null转换字符串 '1' + true // "1true" 规则1,true转换字符串 '1' + 1n // '11' 比较特殊字符串和BigInt相加,BigInt转换为字符串 1 + undefined // NaN 规则2,undefined转换数字相加NaN 1 + null // 1 规则2,null转换为0 1 + true // 2 规则2,true转换为1,二者相加为2 1 + 1n // 错误 不能把BigInt和Number类型直接混合相加 '1' + 3 // '13' 规则3,字符串拼接整体来看,如果数据中有字符串,JavaScript 类型转换还是更倾向于转换成字符串,因为第三条规则中可以看到,在字符串和数字相加的过程中最后返回的还是字符串,这里需要关注一下null 和 undefined 的区别?首先 Undefined 和 Null 都是基本数据类型,这两个基本数据类型分别都只有一个值,就是 undefined 和 null。undefined 代表的含义是未定义, null 代表的含义是空对象(其实不是真的对象,请看下面的注意!)。一般变量声明了但还没有定义的时候会返回 undefined,null 主要用于赋值给一些可能会返回对象的变量,作为初始化。其实 null 不是对象,虽然 typeof null 会输出 object,但是这只是 JS 存在的一个悠久 Bug。在 JS 的最初版本中使用的是 32 位系统,为了性能考虑使用低位存储变量的类型信息,000 开头代表是对象,然而 null 表示为全零,所以将它错误的判断为 object 。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。undefined 在 js 中不是一个保留字,这意味着我们可以使用 undefined 来作为一个变量名,这样的做法是非常危险的,它会影响我们对 undefined 值的判断。但是我们可以通过一些方法获得安全的 undefined 值,比如说 void 0。当我们对两种类型使用 typeof 进行判断的时候,Null 类型化会返回 “object”,这是一个历史遗留的问题。当我们使用双等号对两种类型的值进行比较时会返回 true,使用三个等号时会返回 false。
0
0
0
浏览量716
前端码农

浏览器渲染原理-怎么优化我们的页面

优化策略关键资源个数越多,首次页面加载时间就会越长关键资源的大小,内容越小,下载时间越短优化白屏:内联css和内联js移除文件下载,较小文件体积预渲染,打包时进行预渲染使用SSR加速首屏加载(耗费服务端资源),有利于SEO优化。 首屏利用服务端渲染,后续交互采用客户端渲染什么是Perfomance API衡量和分析各种性能指标对于确保 web 应用的速度非常重要。Performance API 提供了重要的内置指标,并能够将你自己的测量结果添加到浏览器的性能时间线(performance timeline)中。性能时间线使用高精度的时间戳,且可以在开发者工具中显示。你还可以将相关数据发送到用于分析的端点,以根据时间记录性能指标。关键时间节点描述含义TTFBtime to first byte(首字节时间)从请求到数据返回第一个字节所消耗时间TTITime to Interactive(可交互时间)DOM树构建完毕,代表可以绑定事件DCLDOMContentLoaded (事件耗时)当 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发LonLoad (事件耗时)当依赖的资源全部加载完毕之后才会触发FPFirst Paint(首次绘制)第一个像素点绘制到屏幕的时间FCPFirst Contentful Paint(首次内容绘制)首次绘制任何文本,图像,非空白节点的时间FMPFirst Meaningful paint(首次有意义绘制)首次有意义绘制是页面可用性的量度标准LCPLargest Contentful Paint(最大内容渲染)在viewport中最大的页面元素加载的时间FIDFirst Input Delay(首次输入延迟)用户首次和页面交互(单击链接,点击按钮等)到页面响应交互的时间<div style="background:red;height:100px;width:100px"></div> <h1 elementtiming="meaningful"></h1> <script> window.onload = function () { let ele = document.createElement('h1'); ele.innerHTML = 'zf'; document.body.appendChild(ele) } setTimeout(() => { const { fetchStart, requestStart, responseStart, domInteractive, domContentLoadedEventEnd, loadEventStart } = performance.timing; let TTFB = responseStart - requestStart; // ttfb let TTI = domInteractive - fetchStart; // tti let DCL = domContentLoadedEventEnd - fetchStart // dcl let L = loadEventStart - fetchStart; console.log(TTFB, TTI, DCL, L) const paint = performance.getEntriesByType('paint'); const FP = paint[0].startTime; const FCP = paint[1].startTime; // 2s~4s }, 2000); let FMP; new PerformanceObserver((entryList, observer) => { let entries = entryList.getEntries(); FMP = entries[0]; observer.disconnect(); console.log(FMP) }).observe({ entryTypes: ['element'] }); let LCP; new PerformanceObserver((entryList, observer) => { let entries = entryList.getEntries(); LCP = entries[entries.length - 1]; observer.disconnect(); console.log(LCP); // 2.5s-4s }).observe({ entryTypes: ['largest-contentful-paint'] }); let FID; new PerformanceObserver((entryList, observer) => { let firstInput = entryList.getEntries()[0]; if (firstInput) { FID = firstInput.processingStart - firstInput.startTime; observer.disconnect(); console.log(FID) } }).observe({ type: 'first-input', buffered: true }); </script> 网络优化策略减少HTTP请求数,合并JS、CSS,合理内嵌CSS、JS合理设置服务端缓存,提高服务器处理速度。 (强制缓存、对比缓存) sql复制代码// Expires/Cache-Control Etag/if-none-match/last-modified/if-modified-since避免重定向,重定向会降低响应速度 (301,302)使用dns-prefetch,进行DNS预解析采用域名分片技术,将资源放到不同的域名下。接触同一个域名最多处理6个TCP链接问题。采用CDN加速加快访问速度。(指派最近、高度可用)gzip压缩优化 对传输资源进行体积压缩 (html,js,css)加载数据优先级 : preload(预先请求当前页面需要的资源) prefetch(将来页面中使用的资源) 将数据缓存到HTTP缓存中 ini复制代码 <link rel="preload" href="style.css" as="style">关键渲染路径重排(回流)Reflow: 添加元素、删除元素、修改大小、移动元素位置、获取位置相关信息重绘 Repaint:页面中元素样式的改变并不影响它在文档流中的位置。我们应当尽可能减少重绘和回流1.强制同步布局问题JavaScript强制将计算样式和布局操作提前到当前的任务中<div id="app"></div> <script> function reflow() { let el = document.getElementById('app'); let node = document.createElement('h1'); node.innerHTML = 'hello'; el.appendChild(node); // 强制同步布局 console.log(app.offsetHeight); } requestAnimationFrame(reflow) </script> 2.布局抖动(layout thrashing)问题在一段js代码中,反复执行布局操作,就是布局抖动function reflow(){ let el = document.getElementById('app'); let node = document.createElement('h1'); node.innerHTML = 'hello'; el.appendChild(node); // 强制同步布局 console.log(app.offsetHeight); } window.addEventListener('load',function(){ for(let i = 0 ; i<100;i++){ reflow(); } }); 3.减少回流和重绘脱离文档流渲染时给图片增加固定宽高尽量使用css3 动画可以使用will-change提取到单独的图层中静态文件优化1.图片优化图片格式:jpg:适合色彩丰富的照片、banner图;不适合图形文字、图标(纹理边缘有锯齿),不支持透明度png:适合纯色、透明、图标,支持半透明;不适合色彩丰富图片,因为无损存储会导致存储体积大gif:适合动画,可以动的图标;不支持半透明,不适和存储彩色图片webp:适合半透明图片,可以保证图片质量和较小的体积svg格式图片:相比于jpg和jpg它的体积更小,渲染成本过高,适合小且色彩单一的图标;图片优化:避免空src的图片减小图片尺寸,节约用户流量img标签设置alt属性, 提升图片加载失败时的用户体验原生的loading:lazy 图片懒加载 arduino复制代码<img loading="lazy" src="./images/1.jpg" width="300" height="450" />不同环境下,加载不同尺寸和像素的图片 ini复制代码<img src="./images/1.jpg" sizes="(max-width:500px) 100px,(max-width:600px) 200px" srcset="./images/1.jpg 100w, ./images/3.jpg 200w">对于较大的图片可以考虑采用渐进式图片采用base64URL减少图片请求采用雪碧图合并图标图片等2.HTML优化语义化HTML:代码简洁清晰,利于搜索引擎,便于团队开发提前声明字符编码,让浏览器快速确定如何渲染网页内容减少HTML嵌套关系、减少DOM节点数量删除多余空格、空行、注释、及无用的属性等HTML减少iframes使用 (iframe会阻塞onload事件可以动态加载iframe)避免使用table布局3.CSS优化减少伪类选择器、减少样式层数、减少使用通配符避免使用CSS表达式,CSS表达式会频繁求值, 当滚动页面,或者移动鼠标时都会重新计算 (IE6,7) css复制代码background-color: expression( (new Date()).getHours()%2 ? "red" : "yellow" );删除空行、注释、减少无意义的单位、css进行压缩使用外链css,可以对CSS进行缓存添加媒体字段,只加载有效的css文件 ini复制代码<link href="index.css" rel="stylesheet" media="screen and (min-width:1024px)" />CSS contain属性,将元素进行隔离减少@import使用,由于@import采用的是串行加载4.JS优化通过async、defer异步加载文件减少DOM操作,缓存访问过的元素操作不直接应用到DOM上,而应用到虚拟DOM上。最后一次性的应用到DOM上。使用webworker解决程序阻塞问题IntersectionObserver ini复制代码const observer = new IntersectionObserver(function(changes) { changes.forEach(function(element, index) { if (element.intersectionRatio > 0) { observer.unobserve(element.target); element.target.src = element.target.dataset.src; } }); }); function initObserver() { const listItems = document.querySelectorAll('img'); listItems.forEach(function(item) { observer.observe(item); }); } initObserver();虚拟滚动 vertual-scroll-listrequestAnimationFrame、requestIdleCallback尽量避免使用eval, 消耗时间久使用事件委托,减少事件绑定个数。尽量使用canvas动画、CSS动画5.字体优化 @font-face { font-family: "Bmy"; src: url("./HelloQuincy.ttf"); font-display: block; /* block 3s 内不显示, 如果没加载完毕用默认的 */ /* swap 显示老字体 在替换 */ /* fallback 缩短不显示时间, 如果没加载完毕用默认的 ,和block类似*/ /* optional 替换可能用字体 可能不替换*/ } body { font-family: "Bmy" } `FOUT(Flash Of Unstyled Text)` 等待一段时间,如果没加载完成,先显示默认。加载后再进行切换。 `FOIT(Flash Of Invisible Text)`字体加载完毕后显示,加载超时降级系统字体 (白屏)
0
0
0
浏览量2019
前端码农

this 的基本介绍

涵义this关键字是一个非常重要的语法点。毫不夸张地说,不理解它的含义,大部分开发任务都无法完成。前一章已经提到,this可以用在构造函数之中,表示实例对象。除此之外,this还可以用在别的场合。但不管是什么场合,this都有一个共同点:它总是返回一个对象。简单说,this就是属性或方法“当前”所在的对象。this.property上面代码中,this就代表property属性当前所在的对象。下面是一个实际的例子。var person = { name: '张三', describe: function () { return '姓名:'+ this.name; } }; person.describe() // "姓名:张三"上面代码中,this.name表示name属性所在的那个对象。由于this.name是在describe方法中调用,而describe方法所在的当前对象是person,因此this指向person,this.name就是person.name。由于对象的属性可以赋给另一个对象,所以属性所在的当前对象是可变的,即this的指向是可变的。var A = { name: '张三', describe: function () { return '姓名:'+ this.name; } }; var B = { name: '李四' }; B.describe = A.describe; B.describe() // "姓名:李四"上面代码中,A.describe属性被赋给B,于是B.describe就表示describe方法所在的当前对象是B,所以this.name就指向B.name。稍稍重构这个例子,this的动态指向就能看得更清楚。function f() { return '姓名:'+ this.name; } var A = { name: '张三', describe: f }; var B = { name: '李四', describe: f }; A.describe() // "姓名:张三" B.describe() // "姓名:李四"上面代码中,函数f内部使用了this关键字,随着f所在的对象不同,this的指向也不同。只要函数被赋给另一个变量,this的指向就会变。var A = { name: '张三', describe: function () { return '姓名:'+ this.name; } }; var name = '李四'; var f = A.describe; f() // "姓名:李四"上面代码中,A.describe被赋值给变量f,内部的this就会指向f运行时所在的对象(本例是顶层对象)。再看一个网页编程的例子。<input type="text" name="age" size=3 onChange="validate(this, 18, 99);"> <script> function validate(obj, lowval, hival){ if ((obj.value < lowval) || (obj.value > hival)) console.log('Invalid Value!'); } </script>上面代码是一个文本输入框,每当用户输入一个值,就会调用onChange回调函数,验证这个值是否在指定范围。浏览器会向回调函数传入当前对象,因此this就代表传入当前对象(即文本框),然后就可以从this.value上面读到用户的输入值。总结一下,JavaScript 语言之中,一切皆对象,运行环境也是对象,所以函数都是在某个对象之中运行,this就是函数运行时所在的对象(环境)。这本来并不会让用户糊涂,但是 JavaScript 支持运行环境动态切换,也就是说,this的指向是动态的,没有办法事先确定到底指向哪个对象,这才是最让初学者感到困惑的地方。使用场合this主要有以下几个使用场合。(1)全局环境全局环境使用this,它指的就是顶层对象window。this === window // true function f() { console.log(this === window); } f() // true上面代码说明,不管是不是在函数内部,只要是在全局环境下运行,this就是指顶层对象window。(2)构造函数构造函数中的this,指的是实例对象。var Obj = function (p) { this.p = p; };上面代码定义了一个构造函数Obj。由于this指向实例对象,所以在构造函数内部定义this.p,就相当于定义实例对象有一个p属性。var o = new Obj('Hello World!'); o.p // "Hello World!"(3)对象的方法如果对象的方法里面包含this,this的指向就是方法运行时所在的对象。该方法赋值给另一个对象,就会改变this的指向。但是,这条规则很不容易把握。请看下面的代码。var obj ={ foo: function () { console.log(this); } }; obj.foo() // obj上面代码中,obj.foo方法执行时,它内部的this指向obj。但是,下面这几种用法,都会改变this的指向。// 情况一 (obj.foo = obj.foo)() // window // 情况二 (false || obj.foo)() // window // 情况三 (1, obj.foo)() // window上面代码中,obj.foo就是一个值。这个值真正调用的时候,运行环境已经不是obj了,而是全局环境,所以this不再指向obj。可以这样理解,JavaScript 引擎内部,obj和obj.foo储存在两个内存地址,称为地址一和地址二。obj.foo()这样调用时,是从地址一调用地址二,因此地址二的运行环境是地址一,this指向obj。但是,上面三种情况,都是直接取出地址二进行调用,这样的话,运行环境就是全局环境,因此this指向全局环境。上面三种情况等同于下面的代码。// 情况一 (obj.foo = function () { console.log(this); })() // 等同于 (function () { console.log(this); })() // 情况二 (false || function () { console.log(this); })() // 情况三 (1, function () { console.log(this); })()如果this所在的方法不在对象的第一层,这时this只是指向当前一层的对象,而不会继承更上面的层。var a = { p: 'Hello', b: { m: function() { console.log(this.p); } } }; a.b.m() // undefined上面代码中,a.b.m方法在a对象的第二层,该方法内部的this不是指向a,而是指向a.b,因为实际执行的是下面的代码。var b = { m: function() { console.log(this.p); } }; var a = { p: 'Hello', b: b }; (a.b).m() // 等同于 b.m()如果要达到预期效果,只有写成下面这样。var a = { b: { m: function() { console.log(this.p); }, p: 'Hello' } };如果这时将嵌套对象内部的方法赋值给一个变量,this依然会指向全局对象。var a = { b: { m: function() { console.log(this.p); }, p: 'Hello' } }; var hello = a.b.m; hello() // undefined上面代码中,m是多层对象内部的一个方法。为求简便,将其赋值给hello变量,结果调用时,this指向了顶层对象。为了避免这个问题,可以只将m所在的对象赋值给hello,这样调用时,this的指向就不会变。var hello = a.b; hello.m() // Hello使用注意点避免多层 this由于this的指向是不确定的,所以切勿在函数中包含多层的this。var o = { f1: function () { console.log(this); var f2 = function () { console.log(this); }(); } } o.f1() // Object // Window上面代码包含两层this,结果运行后,第一层指向对象o,第二层指向全局对象,因为实际执行的是下面的代码。var temp = function () { console.log(this); }; var o = { f1: function () { console.log(this); var f2 = temp(); } }一个解决方法是在第二层改用一个指向外层this的变量。var o = { f1: function() { console.log(this); var that = this; var f2 = function() { console.log(that); }(); } } o.f1() // Object // Object上面代码定义了变量that,固定指向外层的this,然后在内层使用that,就不会发生this指向的改变。事实上,使用一个变量固定this的值,然后内层函数调用这个变量,是非常常见的做法,请务必掌握。JavaScript 提供了严格模式,也可以硬性避免这种问题。严格模式下,如果函数内部的this指向顶层对象,就会报错。var counter = { count: 0 }; counter.inc = function () { 'use strict'; this.count++ }; var f = counter.inc; f() // TypeError: Cannot read property 'count' of undefined上面代码中,inc方法通过'use strict'声明采用严格模式,这时内部的this一旦指向顶层对象,就会报错。避免数组处理方法中的 this数组的map和foreach方法,允许提供一个函数作为参数。这个函数内部不应该使用this。var o = { v: 'hello', p: [ 'a1', 'a2' ], f: function f() { this.p.forEach(function (item) { console.log(this.v + ' ' + item); }); } } o.f() // undefined a1 // undefined a2上面代码中,foreach方法的回调函数中的this,其实是指向window对象,因此取不到o.v的值。原因跟上一段的多层this是一样的,就是内层的this不指向外部,而指向顶层对象。解决这个问题的一种方法,就是前面提到的,使用中间变量固定this。var o = { v: 'hello', p: [ 'a1', 'a2' ], f: function f() { var that = this; this.p.forEach(function (item) { console.log(that.v+' '+item); }); } } o.f() // hello a1 // hello a2另一种方法是将this当作foreach方法的第二个参数,固定它的运行环境。var o = { v: 'hello', p: [ 'a1', 'a2' ], f: function f() { this.p.forEach(function (item) { console.log(this.v + ' ' + item); }, this); } } o.f() // hello a1 // hello a2避免回调函数中的 this回调函数中的this往往会改变指向,最好避免使用。var o = new Object(); o.f = function () { console.log(this === o); } // jQuery 的写法 $('#button').on('click', o.f);上面代码中,点击按钮以后,控制台会显示false。原因是此时this不再指向o对象,而是指向按钮的 DOM 对象,因为f方法是在按钮对象的环境中被调用的。这种细微的差别,很容易在编程中忽视,导致难以察觉的错误。为了解决这个问题,可以采用下面的一些方法对this进行绑定,也就是使得this固定指向某个对象,减少不确定性。绑定 this 的方法this的动态切换,固然为 JavaScript 创造了巨大的灵活性,但也使得编程变得困难和模糊。有时,需要把this固定下来,避免出现意想不到的情况。JavaScript 提供了call、apply、bind这三个方法,来切换/固定this的指向。Function.prototype.call()函数实例的call方法,可以指定函数内部this的指向(即函数执行时所在的作用域),然后在所指定的作用域中,调用该函数。var obj = {}; var f = function () { return this; }; f() === window // true f.call(obj) === obj // true上面代码中,全局环境运行函数f时,this指向全局环境(浏览器为window对象);call方法可以改变this的指向,指定this指向对象obj,然后在对象obj的作用域中运行函数f。call方法的参数,应该是一个对象。如果参数为空、null和undefined,则默认传入全局对象。var n = 123; var obj = { n: 456 }; function a() { console.log(this.n); } a.call() // 123 a.call(null) // 123 a.call(undefined) // 123 a.call(window) // 123 a.call(obj) // 456上面代码中,a函数中的this关键字,如果指向全局对象,返回结果为123。如果使用call方法将this关键字指向obj对象,返回结果为456。可以看到,如果call方法没有参数,或者参数为null或undefined,则等同于指向全局对象。如果call方法的参数是一个原始值,那么这个原始值会自动转成对应的包装对象,然后传入call方法。var f = function () { return this; }; f.call(5) // Number {[[PrimitiveValue]]: 5}上面代码中,call的参数为5,不是对象,会被自动转成包装对象(Number的实例),绑定f内部的this。call方法还可以接受多个参数。func.call(thisValue, arg1, arg2, ...)call的第一个参数就是this所要指向的那个对象,后面的参数则是函数调用时所需的参数。function add(a, b) { return a + b; } add.call(this, 1, 2) // 3上面代码中,call方法指定函数add内部的this绑定当前环境(对象),并且参数为1和2,因此函数add运行后得到3。call方法的一个应用是调用对象的原生方法。var obj = {}; obj.hasOwnProperty('toString') // false // 覆盖掉继承的 hasOwnProperty 方法 obj.hasOwnProperty = function () { return true; }; obj.hasOwnProperty('toString') // true Object.prototype.hasOwnProperty.call(obj, 'toString') // false上面代码中,hasOwnProperty是obj对象继承的方法,如果这个方法一旦被覆盖,就不会得到正确结果。call方法可以解决这个问题,它将hasOwnProperty方法的原始定义放到obj对象上执行,这样无论obj上有没有同名方法,都不会影响结果。Function.prototype.apply()apply方法的作用与call方法类似,也是改变this指向,然后再调用该函数。唯一的区别就是,它接收一个数组作为函数执行时的参数,使用格式如下。func.apply(thisValue, [arg1, arg2, ...])apply方法的第一个参数也是this所要指向的那个对象,如果设为null或undefined,则等同于指定全局对象。第二个参数则是一个数组,该数组的所有成员依次作为参数,传入原函数。原函数的参数,在call方法中必须一个个添加,但是在apply方法中,必须以数组形式添加。function f(x, y){ console.log(x + y); } f.call(null, 1, 1) // 2 f.apply(null, [1, 1]) // 2上面代码中,f函数本来接受两个参数,使用apply方法以后,就变成可以接受一个数组作为参数。利用这一点,可以做一些有趣的应用。(1)找出数组最大元素JavaScript 不提供找出数组最大元素的函数。结合使用apply方法和Math.max方法,就可以返回数组的最大元素。var a = [10, 2, 4, 15, 9]; Math.max.apply(null, a) // 15(2)将数组的空元素变为undefined通过apply方法,利用Array构造函数将数组的空元素变成undefined。Array.apply(null, ['a', ,'b']) // [ 'a', undefined, 'b' ]空元素与undefined的差别在于,数组的forEach方法会跳过空元素,但是不会跳过undefined。因此,遍历内部元素的时候,会得到不同的结果。var a = ['a', , 'b']; function print(i) { console.log(i); } a.forEach(print) // a // b Array.apply(null, a).forEach(print) // a // undefined // b(3)转换类似数组的对象另外,利用数组对象的slice方法,可以将一个类似数组的对象(比如arguments对象)转为真正的数组。Array.prototype.slice.apply({0: 1, length: 1}) // [1] Array.prototype.slice.apply({0: 1}) // [] Array.prototype.slice.apply({0: 1, length: 2}) // [1, undefined] Array.prototype.slice.apply({length: 1}) // [undefined]上面代码的apply方法的参数都是对象,但是返回结果都是数组,这就起到了将对象转成数组的目的。从上面代码可以看到,这个方法起作用的前提是,被处理的对象必须有length属性,以及相对应的数字键。(4)绑定回调函数的对象前面的按钮点击事件的例子,可以改写如下。var o = new Object(); o.f = function () { console.log(this === o); } var f = function (){ o.f.apply(o); // 或者 o.f.call(o); }; // jQuery 的写法 $('#button').on('click', f);上面代码中,点击按钮以后,控制台将会显示true。由于apply方法(或者call方法)不仅绑定函数执行时所在的对象,还会立即执行函数,因此不得不把绑定语句写在一个函数体内。更简洁的写法是采用下面介绍的bind方法。Function.prototype.bind()bind方法用于将函数体内的this绑定到某个对象,然后返回一个新函数。var d = new Date(); d.getTime() // 1481869925657 var print = d.getTime; print() // Uncaught TypeError: this is not a Date object.上面代码中,我们将d.getTime方法赋给变量print,然后调用print就报错了。这是因为getTime方法内部的this,绑定Date对象的实例,赋给变量print以后,内部的this已经不指向Date对象的实例了。bind方法可以解决这个问题。var print = d.getTime.bind(d); print() // 1481869925657上面代码中,bind方法将getTime方法内部的this绑定到d对象,这时就可以安全地将这个方法赋值给其他变量了。bind方法的参数就是所要绑定this的对象,下面是一个更清晰的例子。var counter = { count: 0, inc: function () { this.count++; } }; var func = counter.inc.bind(counter); func(); counter.count // 1上面代码中,counter.inc方法被赋值给变量func。这时必须用bind方法将inc内部的this,绑定到counter,否则就会出错。this绑定到其他对象也是可以的。var counter = { count: 0, inc: function () { this.count++; } }; var obj = { count: 100 }; var func = counter.inc.bind(obj); func(); obj.count // 101上面代码中,bind方法将inc方法内部的this,绑定到obj对象。结果调用func函数以后,递增的就是obj内部的count属性。bind还可以接受更多的参数,将这些参数绑定原函数的参数。var add = function (x, y) { return x * this.m + y * this.n; } var obj = { m: 2, n: 2 }; var newAdd = add.bind(obj, 5); newAdd(5) // 20上面代码中,bind方法除了绑定this对象,还将add函数的第一个参数x绑定成5,然后返回一个新函数newAdd,这个函数只要再接受一个参数y就能运行了。如果bind方法的第一个参数是null或undefined,等于将this绑定到全局对象,函数运行时this指向顶层对象(浏览器为window)。function add(x, y) { return x + y; } var plus5 = add.bind(null, 5); plus5(10) // 15上面代码中,函数add内部并没有this,使用bind方法的主要目的是绑定参数x,以后每次运行新函数plus5,就只需要提供另一个参数y就够了。而且因为add内部没有this,所以bind的第一个参数是null,不过这里如果是其他对象,也没有影响。bind方法有一些使用注意点。(1)每一次返回一个新函数bind方法每运行一次,就返回一个新函数,这会产生一些问题。比如,监听事件的时候,不能写成下面这样。element.addEventListener('click', o.m.bind(o));上面代码中,click事件绑定bind方法生成的一个匿名函数。这样会导致无法取消绑定,所以,下面的代码是无效的。element.removeEventListener('click', o.m.bind(o));正确的方法是写成下面这样:var listener = o.m.bind(o); element.addEventListener('click', listener); // ... element.removeEventListener('click', listener);(2)结合回调函数使用回调函数是 JavaScript 最常用的模式之一,但是一个常见的错误是,将包含this的方法直接当作回调函数。解决方法就是使用bind方法,将counter.inc绑定counter。var counter = { count: 0, inc: function () { 'use strict'; this.count++; } }; function callIt(callback) { callback(); } callIt(counter.inc.bind(counter)); counter.count // 1上面代码中,callIt方法会调用回调函数。这时如果直接把counter.inc传入,调用时counter.inc内部的this就会指向全局对象。使用bind方法将counter.inc绑定counter以后,就不会有这个问题,this总是指向counter。还有一种情况比较隐蔽,就是某些数组方法可以接受一个函数当作参数。这些函数内部的this指向,很可能也会出错。var obj = { name: '张三', times: [1, 2, 3], print: function () { this.times.forEach(function (n) { console.log(this.name); }); } }; obj.print() // 没有任何输出上面代码中,obj.print内部this.times的this是指向obj的,这个没有问题。但是,forEach方法的回调函数内部的this.name却是指向全局对象,导致没有办法取到值。稍微改动一下,就可以看得更清楚。obj.print = function () { this.times.forEach(function (n) { console.log(this === window); }); }; obj.print() // true // true // true解决这个问题,也是通过bind方法绑定this。obj.print = function () { this.times.forEach(function (n) { console.log(this.name); }.bind(this)); }; obj.print() // 张三 // 张三 // 张三(3)结合call方法使用利用bind方法,可以改写一些 JavaScript 原生方法的使用形式,以数组的slice方法为例。[1, 2, 3].slice(0, 1) // [1] // 等同于 Array.prototype.slice.call([1, 2, 3], 0, 1) // [1]上面的代码中,数组的slice方法从[1, 2, 3]里面,按照指定位置和长度切分出另一个数组。这样做的本质是在[1, 2, 3]上面调用Array.prototype.slice方法,因此可以用call方法表达这个过程,得到同样的结果。call方法实质上是调用Function.prototype.call方法,因此上面的表达式可以用bind方法改写。var slice = Function.prototype.call.bind(Array.prototype.slice); slice([1, 2, 3], 0, 1) // [1]上面代码的含义就是,将Array.prototype.slice变成Function.prototype.call方法所在的对象,调用时就变成了Array.prototype.slice.call。类似的写法还可以用于其他数组方法。var push = Function.prototype.call.bind(Array.prototype.push); var pop = Function.prototype.call.bind(Array.prototype.pop); var a = [1 ,2 ,3]; push(a, 4) a // [1, 2, 3, 4] pop(a) a // [1, 2, 3]如果再进一步,将Function.prototype.call方法绑定到Function.prototype.bind对象,就意味着bind的调用形式也可以被改写。function f() { console.log(this.v); } var o = { v: 123 }; var bind = Function.prototype.call.bind(Function.prototype.bind); bind(f, o)() // 123上面代码的含义就是,将Function.prototype.bind方法绑定在Function.prototype.call上面,所以bind方法就可以直接使用,不需要在函数实例上使用。
0
0
0
浏览量1018
前端码农

浏览器中的页面:重绘重排及合成?如何提高页面渲染性能

浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第五篇DOM树从网络传给渲染引擎的HTML文件字节流是无法直接被渲染引擎理解的,所以要将其转化为渲染引擎能够理解的内部结构,这个结构就是DOM。DOM提供了对HTML文档结构化的表述。在渲染引擎中,DOM有三个层面的作用。从页面的视角来看,DOM是生成页面的基础数据结构。从JavaScript脚本视角来看,DOM提供给JavaScript脚本操作的接口,通过这套接口,JavaScript可以对DOM结构进行访问,从而改变文档的结构、样式和内容。从安全视角来看,DOM是一道安全防护线,一些不安全的内容在DOM解析阶段就被拒之门外了。简言之,DOM是表述HTML的内部数据结构,它会将Web页面和JavaScript脚本连接起来,并过滤一些不安全的内容。在渲染引擎内部,有一个叫HTML解析器(HTMLParser) 的模块,它的职责就是负责将HTML字节流转换为DOM结构。HTML解析器并不是等整个文档加载完成之后再解析的,而是网络进程加载了多少数据,HTML解析器便解析多少数据:网络进程接收到响应头之后,会根据响应头中的content-type字段来判断文件的类型,比如content-type的值是“text/html”,浏览器就会判断这是一个HTML类型的文件,然后为该请求选择或者创建一个渲染进程。渲染进程准备好之后,网络进程和渲染进程之间会建立一个共享数据的管道,网络进程接收到数据后就往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据传递给HTML解析器,它会动态接收字节流,并将其解析为DOM。JavaScript会影响DOM生成当执行到JavaScript标签时,解析器会暂停整个DOM的解析,执行JavaScript代码,不过执行JavaScript时,需要先下载JavaScript代码。JavaScript文件的下载过程会阻塞DOM解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript文件大小等因素的影响。不过Chrome浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析HTML文件中包含的JavaScript、CSS等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。有一些相关的策略来规避JavaScript线程阻塞DOM,比如使用CDN来加速JavaScript文件的加载,压缩JavaScript文件的体积。另外,如果JavaScript文件中没有操作DOM相关代码,就可以将该JavaScript脚本设置为异步加载,通过async 或defer来标记代码。async和defer虽然都是异步的,不过还有一些差异,使用async标志的脚本文件一旦加载完成,会立即执行,与在页面中出现的顺序无关;而使用了defer标记的脚本文件,需要在DOMContentLoaded事件之前执行,与出现顺序有关,dom加载完毕后按出现顺序依次执行。如果JavaScript代码出现了类似 div.style.color = ‘red' 的语句,在执行JavaScript之前,就需要先解析JavaScript语句之上所有的CSS样式,因为它是用来操纵CSSOM的。所以如果代码里引用了外部的CSS文件,在执行JavaScript之前,还需要等待外部的CSS文件下载完成,并解析生成CSSOM对象之后,才能执行JavaScript脚本。而JavaScript引擎在解析JavaScript之前,是不知道JavaScript是否操纵了CSSOM的,所以渲染引擎在遇到JavaScript脚本时,不管该脚本是否操纵了CSSOM,都会执行CSS文件下载,解析操作,再执行JavaScript脚本。所以说JavaScript脚本是依赖样式表的,这又多了一个阻塞过程。JavaScript会阻塞DOM生成,而样式文件又会阻塞JavaScript的执行,所以在实际的工程中需要重点关注JavaScript文件和样式表文件,使用不当会影响到页面性能的。渲染流水线:CSS如何影响首次加载时的白屏时间?<link href="theme.css" rel="stylesheet"> <div>geekbang com</div> <script src="foo.js"></script> <div>geekbang com</div>以上是页面HTML代码以及渲染流程图,借此分析渲染流水线:首先是发起主页面的请求,这个发起请求方可能是渲染进程,也有可能是浏览器进程,发起的请求被送到网络进程中去执行。网络进程接收到返回的HTML数据之后,将其发送给渲染进程,渲染进程会解析HTML数据并构建DOM。需要特别注意下,请求HTML数据和构建DOM中间有一段空闲时间,这个空闲时间有可能成为页面渲染的瓶颈。当渲染进程接收HTML文件字节流时,会先开启一个预解析线程,如果遇到JavaScript文件或者CSS文件,预解析线程会提前下载这些数据。对于上面的代码,HTML预解析器识别出来了有CSS文件和JavaScript文件需要下载,然后就同时发起这两个文件的下载请求,这两个文件的下载过程是重叠的,所以下载时间按照最久的那个文件来算。不管CSS文件和JavaScript文件谁先到达,都要先等到CSS文件下载完成并生成CSSOM,然后再执行JavaScript脚本,最后再继续构建DOM,构建布局树,绘制页面。还有一个空闲时间需要注意,就是在DOM构建结束之后、theme.css文件还未下载完成的这段时间内,渲染流水线无事可做,因为下一步是合成布局树,而合成布局树需要CSSOM和DOM,所以这里需要等待CSS加载结束并解析成CSSOM。和HTML一样,渲染引擎也是无法直接理解CSS文件内容的,所以需要将其解析成渲染引擎能够理解的结构,这个结构就是CSSOM。和DOM一样,CSSOM也具有两个作用,第一个是提供给JavaScript操作样式表的能力,第二个是为布局树的合成提供基础的样式信息。 因此渲染流水线也需要等待CSSOM。等DOM和CSSOM都构建好之后,渲染引擎就会构造布局树。布局树的结构基本上就是复制DOM树的结构,不同之处在于DOM树中那些不需要显示的元素会被过滤掉,如display:none属性的元素、head标签、script标签等。复制好基本的布局树结构之后,渲染引擎会为对应的DOM元素选择对应的样式信息,这个过程就是样式计算。样式计算完成之后,渲染引擎还需要计算布局树中每个元素对应的几何位置,这个过程就是计算布局。通过样式计算和计算布局就完成了最终布局树的构建。影响页面展示的因素以及优化策略渲染流水线影响到了首次页面展示的速度,而首次页面展示的速度又直接影响到了用户体验,所以分析渲染流水线的目的就是为了找出一些影响到首屏展示的因素,然后再基于这些因素做一些针对性的调整。从发起URL请求开始,到首次显示页面的内容,在视觉上经历三个阶段。等请求发出去之后,到提交数据阶段,这时页面展示出来的还是之前页面的内容。提交数据之后渲染进程会创建一个空白页面,这段时间称为解析白屏,并等待CSS文件和JavaScript文件的加载完成,生成CSSOM和DOM,然后合成布局树,最后还要经过一系列的步骤准备首次渲染。第三个阶段,等首次渲染完成之后,就开始进入完整页面的生成阶段了,然后页面会一点点被绘制出来。影响第一个阶段的因素主要是网络或者是服务器处理。第二个阶段主要问题是白屏时间,如果白屏时间过久,就会影响到用户体验。为了缩短白屏时间,需要挨个分析这个阶段的主要任务,包括了解析HTML、下载CSS、下载JavaScript、生成CSSOM、执行JavaScript、生成布局树、绘制页面一系列操作。通常情况下的瓶颈主要体现在下载CSS文件、下载JavaScript文件和执行JavaScript。所以要想缩短白屏时长,可以有以下策略:通过内联JavaScript、内联CSS来移除这两种类型的文件下载,这样获取到HTML文件之后就可以直接开始渲染流程了。但并不是所有的场合都适合内联,那么还可以尽量减少文件大小,比如通过webpack等工具移除一些不必要的注释,并压缩JavaScript文件。还可以将一些不需要在解析HTML阶段使用的JavaScript标记上sync或者defer。对于大的CSS文件,可以通过媒体查询属性,将其拆分为多个不同用途的CSS文件,这样只有在特定的场景下才会加载特定的CSS文件。分层和合成机制:为什么CSS动画比JavaScript高效?显示器是怎么显示图像的每个显示器都有固定的刷新频率,通常是60HZ,也就是每秒更新60张图片,更新的图片都来自于显卡中的前缓冲区,显示器所做的任务很简单,就是每秒固定读取60次前缓冲区中的图像,并将读取的图像显示到显示器上。那么这里显卡做什么呢? 显卡的职责就是合成新的图像,并将图像保存到后缓冲区中,一旦显卡把合成的图像写到后缓冲区,系统就会让后缓冲区和前缓冲区互换,这样就能保证显示器能读取到最新显卡合成的图像。通常情况下,显卡的更新频率和显示器的刷新频率是一致的。但有时候,在一些复杂的场景中,显卡处理一张图片的速度会变慢,这样就会造成视觉上的卡顿。帧 VS 帧率大多数设备屏幕的更新频率是60次/秒,这也就意味着正常情况下要实现流畅的动画效果,渲染引擎需要每秒更新60张图片到显卡的后缓冲区。渲染流水线生成的每一副图片称为一帧,渲染流水线每秒更新了多少帧称为帧率,比如滚动过程中1秒更新了60帧,那么帧率就是60Hz(或者60FPS)。如何生成一帧图像任意一帧的生成方式,有重排、重绘和合成三种方式。这三种方式的渲染路径是不同的,通常渲染路径越长,生成图像花费的时间就越多。重排,它需要重新根据CSSOM和DOM来计算布局树,这样生成一幅图片时,会让整个渲染流水线的每个阶段都执行一遍,如果布局复杂的话,就很难保证渲染的效率了。重绘,因为没有了重新布局的阶段,操作效率稍微高点,但是依然需要重新计算绘制信息,并触发绘制操作之后的一系列操作。合成,相较于重排和重绘,合成操作的路径就显得非常短了,并不需要触发布局和绘制两个阶段,如果采用了GPU,合成的效率会非常高。Chrome浏览器是怎么实现合成操作的,可以用三个词来概括总结:分层、分块和合成:分层和合成如果没有采用分层机制,从布局树直接生成目标图片的话,那么每次页面有很小的变化时,都会触发重排或者重绘机制,这种“牵一发而动全身”的绘制策略会严重影响页面的渲染效率。和PhotoShop的图层类似,PhotoShop中一个项目是由很多图层构成的,每个图层都可以是一张单独图片,可以设置透明度、边框阴影,可以旋转或者设置图层的上下位置,将这些图层叠加在一起后,就能呈现出最终的图片了。将素材分解为多个图层的操作就称为分层,最后将这些图层合并到一起的操作就称为合成。合成操作是在合成线程上完成的,这就意味着在执行合成操作时,是不会影响到主线程执行的。 所以有时候主线程卡住了,但是CSS动画依然能执行。分块如果说分层是从宏观上提升了渲染效率,那么分块则是从微观层面提升了渲染效率。通常情况下,页面的内容都要比屏幕大得多,显示一个页面时,如果等待所有的图层都生成完毕,再进行合成的话,会产生一些不必要的开销,也会让合成图片的时间变得更久。因此,合成线程会将每个图层分割为大小固定的图块,然后优先绘制靠近视口的图块,这样就可以大大加速页面的显示速度。不过有时候, 即使只绘制那些优先级最高的图块,也要耗费不少的时间,因为涉及到一个很关键的因素——纹理上传,这是因为从计算机内存上传到GPU内存的操作会比较慢。如何利用分层技术优化代码开发中经常需要对某个元素做几何形状变换、透明度变换或者一些缩放操作,如果使用JavaScript来写这些效果,会牵涉到整个渲染流水线,所以JavaScript的绘制效率会非常低下。这时可以使用 will-change来告诉渲染引擎会对该元素做一些特效变换,CSS代码如下:.box { will-change: transform, opacity; } 以上代码就是提前告诉渲染引擎box元素将要做几何变换和透明度变换操作,这时候渲染引擎会将该元素单独实现一帧,等这些变换发生时,渲染引擎会通过合成线程直接去处理变换,这些变换并没有涉及到主线程,这样就大大提升了渲染的效率。这也是CSS动画比JavaScript动画高效的原因。所以,如果涉及到一些可以使用合成线程来处理CSS特效或者动画的情况,就尽量使用will-change来提前告诉渲染引擎,让它为该元素准备独立的层。但是凡事都有两面性,每当渲染引擎为一个元素准备一个独立层的时候,它占用的内存也会大大增加,因为从层树开始,后续每个阶段都会多一个层结构,这些都需要额外的内存,所以需要恰当地使用 will-change。页面性能,如何系统的优化页面?所谓页面优化,其实就是指如何让页面更快地显示和响应。一个页面在它不同的阶段,所侧重的关注点是不一样的,所以页面优化,就要分析一个页面生存周期的不同阶段。通常一个页面有三个阶段:加载阶段、交互阶段和关闭阶段。加载阶段,是指从发出请求到渲染出完整页面的过程,影响到这个阶段的主要因素有网络和JavaScript脚本。交互阶段,主要是从页面加载完成到用户交互的整合过程,影响到这个阶段的主要因素是JavaScript脚本。关闭阶段,主要是用户发出关闭指令后页面所做的一些清理操作。重点关注加载阶段和交互阶段,因为影响到体验的因素主要都在这两个阶段加载阶段上图是一个典型的渲染流水线。并非所有的资源都会阻塞页面的首次绘制,比如图片、音频、视频等文件就不会阻塞页面的首次渲染;而JavaScript、首次请求的HTML资源文件、CSS文件是会阻塞首次渲染,因为在构建DOM的过程中需要HTML和JavaScript文件,在构造渲染树的过程中需要用到CSS文件。能阻塞网页首次渲染的资源称为关键资源。基于关键资源,可以细化出来三个影响页面首次渲染的核心因素。第一个是关键资源个数。关键资源个数越多,首次页面的加载时间就会越长。比如上图中的关键资源个数就是3个,1个HTML文件、1个JavaScript和1个CSS文件。第二个是关键资源大小。通常情况下,所有关键资源的内容越小,其整个资源的下载时间也就越短,那么阻塞渲染的时间也就越短。上图中关键资源的大小分别是6KB、8KB和9KB,那么整个关键资源大小就是23KB。第三个是请求关键资源需要多少个RTT(Round Trip Time) 。当使用TCP协议传输一个文件时,比如文件大小是0.1M,由于TCP的特性,这个数据并不是一次传输到服务端的,而是需要拆分成一个个数据包来回多次进行传输的。RTT就是这里的往返时延,表示从发送端发送数据开始,到发送端收到来自接收端的确认,总共经历的时延。通常1个HTTP的数据包在14KB左右,所以1个0.1M的页面就需要拆分成8个包来传输,也就是说需要8个RTT。结合上图来看看它的关键资源请求需要多少个RTT。首先是请求HTML资源,大小是6KB,小于14KB,所以1个RTT就可以解决了。至于JavaScript和CSS文件,这里需要注意的是,由于渲染引擎有一个预解析的线程,在接收到HTML数据之后,预解析线程会快速扫描HTML数据中的关键资源,一旦扫描到了,会立马发起请求,可以认为JavaScript和CSS是同时发起请求的,所以它们的请求是重叠的,那么计算它们的RTT时,只需要计算体积最大的那个数据就可以了。这里最大的是CSS文件(9KB),所以按照9KB来计算,同样由于9KB小于14KB,所以JavaScript和CSS资源也就可以算成1个RTT。也就是说,上图中关键资源请求共花费了2个RTT。了解了影响加载过程中的几个核心因素之后,就可以系统性地考虑优化方案了。总的优化原则就是减少关键资源个数,降低关键资源大小,降低关键资源的RTT次数。如何减少关键资源的个数?一种方式是可以将JavaScript和CSS改成内联的形式,比如上图的JavaScript和CSS,若都改成内联模式,那么关键资源的个数就由3个减少到了1个。另一种方式,如果JavaScript代码没有DOM或者CSSOM的操作,则可以改成async或者defer属性;同样对于CSS,如果不是在构建页面之前加载的,则可以添加媒体取消阻止显现的标志。当JavaScript标签加上了async或者defer、CSSlink 属性之前加上了取消阻止显现的标志后,它们就变成了非关键资源了。如何减少关键资源的大小?可以压缩CSS和JavaScript资源,移除HTML、CSS、JavaScript文件中一些注释内容,也可以通过取消CSS或者JavaScript中关键资源的方式。如何减少关键资源RTT的次数?可以通过减少关键资源的个数和减少关键资源的大小搭配来实现。除此之外,还可以使用CDN来减少每次RTT时长。交互阶段上图为交互渲染流水线示意图。交互阶段的优化,其实就是渲染进程渲染帧的速度,因为在交互阶段,帧的渲染速度决定了交互的流畅度。大部分情况下,生成一个新的帧都是由JavaScript通过修改DOM或者CSSOM来触发的。还有另外一部分帧是由CSS来触发的。如果在计算样式阶段发现有布局信息的修改,就会触发重排操作,然后触发后续渲染流水线的一系列操作,这个代价是非常大的。若在计算样式阶段没有发现有布局信息的修改,只是修改了颜色一类的信息,就不会涉及到布局相关的调整,则跳过布局阶段,直接进入绘制阶段,这个过程叫重绘。不过重绘阶段的代价也是不小的。还有另外一种情况,通过CSS实现一些变形、渐变、动画等特效,这是由CSS触发的,并且是在合成线程上执行的,这个过程称为合成。它不会触发重排或重绘,而且合成操作本身的速度就非常快,所以执行合成是效率最高的方式。交互阶段渲染流水线中有哪些因素影响了帧的生成速度以及如何优化?1. 减少JavaScript脚本执行时间有时JavaScript函数的一次执行时间可能有几百毫秒,这就严重霸占了主线程执行其他渲染任务的时间。针对这种情况可以采用以下两种策略:一种是将一次执行的函数分解为多个任务,使得每次的执行时间不要过久。另一种是采用Web Workers。Web Workers是主线程之外的一个线程,在Web Workers中是可以执行JavaScript脚本的,不过Web Workers中没有DOM、CSSOM环境,这意味着其中是无法通过JavaScript来访问DOM的,所以可以把一些和DOM操作无关且耗时的任务放到Web Workers中去执行。总之,在交互阶段,对JavaScript脚本总的原则就是不要一次霸占太久主线程。2. 避免强制同步布局正常情况下的布局操作,通过DOM接口执行添加元素或者删除元素等操作后,是需要重新计算样式和布局的,这些操作都是在另外的任务中异步完成的,这样做是为了避免当前的任务占用太长的主线程时间。而强制同步布局,是指JavaScript强制将计算样式和布局操作提前到当前的任务中。 如下代码所示就会造成强制同步布局:function foo() { let main_div = document.getElementById("mian_div") let new_node = document.createElement("li") let textnode = document.createTextNode("time.geekbang") new_node.appendChild(textnode); document.getElementById("mian_div").appendChild(new_node); //由于要获取到offsetHeight, //但是此时的offsetHeight还是老的数据, //所以需要立即执行布局操作 console.log(main_div.offsetHeight) } 将新的元素添加到DOM之后,又调用了main_div.offsetHeight来获取新main_div的高度信息。如果要获取到main_div的高度,就需要重新布局,所以这里在获取到main_div的高度之前,JavaScript还需要强制让渲染引擎默认执行一次布局操作。3. 避免布局抖动布局抖动,是指在一次JavaScript执行过程中,多次执行强制布局和抖动操作。如下代码所示,在一个for循环语句里面不断读取属性值,每次读取属性值之前都要进行计算样式和布局,这会大大影响当前函数的执行效率。这种情况的避免方式和强制同步布局一样,都是尽量不要在修改DOM结构时再去查询一些相关值。function foo() { let time_li = document.getElementById("time_li") for (let i = 0; i < 100; i++) { let main_div = document.getElementById("mian_div") let new_node = document.createElement("li") let textnode = document.createTextNode("time.geekbang") new_node.appendChild(textnode); new_node.offsetHeight = time_li.offsetHeight; document.getElementById("mian_div").appendChild(new_node); } } 4. 合理利用CSS合成动画合成动画是直接在合成线程上执行的,这和在主线程上执行的布局、绘制等操作不同,如果主线程被JavaScript或者一些布局任务占用,CSS动画依然能继续执行。所以要尽量利用好CSS合成动画,如果能让CSS处理动画,就尽量交给CSS来操作。另外,如果能提前知道对某个元素执行动画操作,那就最好将其标记为will-change,这是告诉渲染引擎需要将该元素单独生成一个图层。5. 避免频繁的垃圾回收JavaScript使用了自动垃圾回收机制,如果在一些函数中频繁创建临时对象,那么垃圾回收器也会频繁地去执行垃圾回收策略。这样当垃圾回收操作发生时,就会占用主线程,从而影响到其他任务的执行,严重的话还会让用户产生掉帧、不流畅的感觉。所以要尽量避免产生那些临时垃圾数据,可以尽可能优化储存结构,尽可能避免小颗粒对象的产生。为什么会有虚拟DOMDOM的缺陷DOM提供了一组JavaScript接口用来遍历或者修改节点,这套接口包含了getElementById、removeChild、appendChild等方法。通过JavaScript操纵DOM是会影响到整个渲染流水线的,比如常见的重绘、重排、合成等,对于DOM的不当操作还有可能引发强制同步布局和布局抖动的问题,这些操作都会大大降低渲染效率。react中的虚拟DOM以react中的虚拟DOM有两个执行阶段:创建阶段。首先依据JSX和基础数据创建出来虚拟DOM,它反映了真实的DOM树的结构。然后由虚拟DOM树创建出真实DOM树,真实的DOM树生成完后,再触发渲染流水线往屏幕输出页面。更新阶段。如果数据发生了改变,那么就需要根据新的数据创建一个新的虚拟DOM树;然后React比较两个树,找出变化的地方,并把变化的地方一次性更新到真实的DOM树上;最后渲染引擎更新渲染流水线,并生成新的页面。双缓存在开发游戏或者处理其他图像的过程中,屏幕从前缓冲区读取数据然后显示。但是很多图形操作都很复杂且需要大量的运算,比如一幅完整的画面,可能需要计算多次才能完成,如果每次计算完一部分图像,就将其写入缓冲区,那么显示一个稍微复杂点的图像的过程中,看到的页面效果可能是一部分一部分地显示出来,因此在刷新页面的过程中,会让用户感受到界面的闪烁。而使用双缓存,可以让你先将计算的中间结果存放在另一个缓冲区中,等全部的计算结束,该缓冲区已经存储了完整的图形之后,再将该缓冲区的图形数据一次性复制到显示缓冲区,这样就使得整个图像的输出非常稳定。在这里,可以把虚拟DOM看成是DOM的一个buffer,和图形显示一样,它会在完成一次完整的操作之后,再把结果应用到DOM上,这样就能减少一些不必要的更新,同时还能保证DOM的稳定输出。MVC模式MVC的整体结构比较简单,由模型、视图和控制器组成,其核心思想就是将数据和视图分离,视图和模型之间是不允许直接通信的,它们之间的通信都是通过控制器来完成的。通常情况下的通信路径是视图发生了改变,然后通知控制器,控制器再根据情况判断是否需要更新模型数据。当然还可以根据不同的通信路径和控制器不同的实现方式,基于MVC又能衍生出很多其他的模式,如MVP、MVVM等,不过万变不离其宗,它们的基础骨架都是基于MVC而来。上图为基于React和Redux构建MVC模型。在该图中,可以把虚拟DOM看成是MVC的视图部分,其控制器和模型都是由Redux提供的。其具体实现过程如下:图中的控制器是用来监控DOM的变化,一旦DOM发生变化,控制器便会通知模型,让其更新数据;模型数据更新好之后,控制器会通知视图,告诉它模型的数据发生了变化;视图接收到更新消息之后,会根据模型所提供的数据来生成新的虚拟DOM;新的虚拟DOM生成好之后,就需要与之前的虚拟DOM进行比较,找出变化的节点;比较出变化的节点之后,React将变化的虚拟节点应用到DOM上,这样就会触发DOM节点的更新;DOM节点的变化又会触发后续一系列渲染流水线的变化,从而实现页面的更新。
0
0
0
浏览量803
前端码农

构造函数以及继承

构造函数的继承让一个构造函数继承另一个构造函数,是非常常见的需求。这可以分成两步实现。第一步是在子类的构造函数中,调用父类的构造函数。function Sub(value) { Super.call(this); this.prop = value; }上面代码中,Sub是子类的构造函数,this是子类的实例。在实例上调用父类的构造函数Super,就会让子类实例具有父类实例的属性。第二步,是让子类的原型指向父类的原型,这样子类就可以继承父类原型。Sub.prototype = Object.create(Super.prototype); Sub.prototype.constructor = Sub; Sub.prototype.method = '...';上面代码中,Sub.prototype是子类的原型,要将它赋值为Object.create(Super.prototype),而不是直接等于Super.prototype。否则后面两行对Sub.prototype的操作,会连父类的原型Super.prototype一起修改掉。另外一种写法是Sub.prototype等于一个父类实例。Sub.prototype = new Super();上面这种写法也有继承的效果,但是子类会具有父类实例的方法。有时,这可能不是我们需要的,所以不推荐使用这种写法。举例来说,下面是一个Shape构造函数。function Shape() { this.x = 0; this.y = 0; } Shape.prototype.move = function (x, y) { this.x += x; this.y += y; console.info('Shape moved.'); };我们需要让Rectangle构造函数继承Shape。// 第一步,子类继承父类的实例 function Rectangle() { Shape.call(this); // 调用父类构造函数 } // 另一种写法 function Rectangle() { this.base = Shape; this.base(); } // 第二步,子类继承父类的原型 Rectangle.prototype = Object.create(Shape.prototype); Rectangle.prototype.constructor = Rectangle;采用这样的写法以后,instanceof运算符会对子类和父类的构造函数,都返回true。var rect = new Rectangle(); rect.move(1, 1) // 'Shape moved.' rect instanceof Rectangle // true rect instanceof Shape // true上面代码中,子类是整体继承父类。有时只需要单个方法的继承,这时可以采用下面的写法。ClassB.prototype.print = function() { ClassA.prototype.print.call(this); // some code }上面代码中,子类B的print方法先调用父类A的print方法,再部署自己的代码。这就等于继承了父类A的print方法。多重继承JavaScript 不提供多重继承功能,即不允许一个对象同时继承多个对象。但是,可以通过变通方法,实现这个功能。function M1() { this.hello = 'hello'; } function M2() { this.world = 'world'; } function S() { M1.call(this); M2.call(this); } // 继承 M1 S.prototype = Object.create(M1.prototype); // 继承链上加入 M2 Object.assign(S.prototype, M2.prototype); // 指定构造函数 S.prototype.constructor = S; var s = new S(); s.hello // 'hello:' s.world // 'world'上面代码中,子类S同时继承了父类M1和M2。这种模式又称为 Mixin(混入)。模块随着网站逐渐变成”互联网应用程序”,嵌入网页的JavaScript代码越来越庞大,越来越复杂。网页越来越像桌面程序,需要一个团队分工协作、进度管理、单元测试等等……开发者不得不使用软件工程的方法,管理网页的业务逻辑。JavaScript模块化编程,已经成为一个迫切的需求。理想情况下,开发者只需要实现核心的业务逻辑,其他都可以加载别人已经写好的模块。但是,JavaScript不是一种模块化编程语言,ES5不支持”类”(class),更遑论”模块”(module)了。ES6正式支持”类”和”模块”,但还没有成为主流。JavaScript社区做了很多努力,在现有的运行环境中,实现模块的效果。基本的实现方法模块是实现特定功能的一组属性和方法的封装。只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。function m1() { //... } function m2() { //... }上面的函数m1()和m2(),组成一个模块。使用的时候,直接调用就行了。这种做法的缺点很明显:”污染”了全局变量,无法保证不与其他模块发生变量名冲突,而且模块成员之间看不出直接关系。为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面。var module1 = new Object({  _count : 0,  m1 : function (){   //...  },  m2 : function (){  //...  } });上面的函数m1和m2,都封装在module1对象里。使用的时候,就是调用这个对象的属性。module1.m1();但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。module1._count = 5;封装私有变量:构造函数的写法我们可以利用构造函数,封装私有变量。function StringBuilder() { var buffer = []; this.add = function (str) { buffer.push(str); }; this.toString = function () { return buffer.join(''); }; }这种方法将私有变量封装在构造函数中,违反了构造函数与实例对象相分离的原则。并且,非常耗费内存。function StringBuilder() { this._buffer = []; } StringBuilder.prototype = { constructor: StringBuilder, add: function (str) { this._buffer.push(str); }, toString: function () { return this._buffer.join(''); } };这种方法将私有变量放入实例对象中,好处是看上去更自然,但是它的私有变量可以从外部读写,不是很安全。封装私有变量:立即执行函数的写法使用“立即执行函数”(Immediately-Invoked Function Expression,IIFE),将相关的属性和方法封装在一个函数作用域里面,可以达到不暴露私有成员的目的。var module1 = (function () {  var _count = 0;  var m1 = function () {   //...  };  var m2 = function () {   //...  };  return {   m1 : m1,   m2 : m2  }; })();使用上面的写法,外部代码无法读取内部的_count变量。console.info(module1._count); //undefined上面的module1就是JavaScript模块的基本写法。下面,再对这种写法进行加工。模块的放大模式如果一个模块很大,必须分成几个部分,或者一个模块需要继承另一个模块,这时就有必要采用“放大模式”(augmentation)。var module1 = (function (mod){  mod.m3 = function () {   //...  };  return mod; })(module1);上面的代码为module1模块添加了一个新方法m3(),然后返回新的module1模块。在浏览器环境中,模块的各个部分通常都是从网上获取的,有时无法知道哪个部分会先加载。如果采用上面的写法,第一个执行的部分有可能加载一个不存在空对象,这时就要采用”宽放大模式”(Loose augmentation)。var module1 = ( function (mod){  //...  return mod; })(window.module1 || {});与”放大模式”相比,“宽放大模式”就是“立即执行函数”的参数可以是空对象。输入全局变量独立性是模块的重要特点,模块内部最好不与程序的其他部分直接交互。为了在模块内部调用全局变量,必须显式地将其他变量输入模块。var module1 = (function ($, YAHOO) {  //... })(jQuery, YAHOO);上面的module1模块需要使用jQuery库和YUI库,就把这两个库(其实是两个模块)当作参数输入module1。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。立即执行函数还可以起到命名空间的作用。(function($, window, document) { function go(num) { } function handleEvents() { } function initialize() { } function dieCarouselDie() { } //attach to the global scope window.finalCarousel = { init : initialize, destroy : dieCouraselDie } })( jQuery, window, document );上面代码中,finalCarousel对象输出到全局,对外暴露init和destroy接口,内部方法go、handleEvents、initialize、dieCarouselDie都是外部无法调用的。
0
0
0
浏览量1822
前端码农

浏览器安全:xss及CSRF攻击有何特点?如何防御

浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第七篇浏览器安全浏览器安全可以分为三大块——Web页面安全、浏览器网络安全和浏览器系统安全。web页面安全什么是同源策略如果两个URL的协议、域名和端口都相同,就称这两个URL同源。 浏览器默认两个相同的源之间是可以相互访问资源和操作DOM的。具体来讲,同源策略主要表现在DOM、Web数据和网络这三个层面:DOM层面。同源策略限制了来自不同源的JavaScript脚本对当前DOM对象读和写的操作。数据层面。同源策略限制了不同源的站点读取当前站点的Cookie、IndexDB、LocalStorage等数据。网络层面。同源策略限制了通过XMLHttpRequest等方式将站点的数据发送给不同源的站点安全和便利性之间的权衡同源策略会隔离不同源的DOM、页面数据和网络通信,进而实现Web页面的安全性。不过安全性和便利性是相互对立的,让不同的源之间绝对隔离,无疑是最安全的措施,但这也会使得Web项目难以开发和使用。因此就要在这之间做出权衡,出让一些安全性来满足灵活性;而出让安全性又带来了很多安全问题,最典型的是XSS攻击和CSRF攻击。同源策略的安全性主要有两方面:页面中可以嵌入第三方资源:Web世界是开放的,可以接入任何资源,而同源策略要让一个页面的所有资源都来自于同一个源,也就是要将该页面的所有HTML文件、JavaScript文件、CSS文件、图片等资源都部署在同一台服务器上,这无疑违背了Web的初衷,也带来了诸多限制。比如将不同的资源部署到不同的CDN上时,CDN上的资源就部署在另外一个域名上,因此就需要同源策略对页面的引用资源开一个“口子”,让其任意引用外部文件。为了解决XSS攻击,浏览器中引入了内容安全策略,称为CSP。CSP的核心思想是让服务器决定浏览器能够加载哪些资源,让服务器决定浏览器是否能够执行内联JavaScript代码。通过这些手段就可以大大减少XSS攻击。跨域资源共享和跨文档消息机制: 为了能在不同页面间通过XMLHttpRequest或者Fetch来请求,浏览器还引入了跨域资源共享(CORS) ,使用该机制可以进行跨域访问控制,从而使跨域数据传输得以安全进行。除此之外,两个不同源的页面,无法相互操纵DOM。不过在实际应用中,经常需要两个不同源的DOM之间进行通信,于是浏览器中又引入了跨文档消息机制,可以通过window.postMessage的JavaScript接口来和不同源的DOM进行通信。XSS攻击XSS全称是Cross Site Scripting,为了与“CSS”区分开来,故简称XSS,翻译过来就是“跨站脚本”。XSS攻击是指黑客往HTML文件中或者DOM中注入恶意脚本,从而在用户浏览页面时利用注入的恶意脚本对用户实施攻击的一种手段。跨站脚本攻击可以做很多事,比如以下几点:可以窃取Cookie信息。恶意JavaScript可以通过“document.cookie”获取Cookie信息,然后通过XMLHttpRequest或者Fetch加上CORS功能将数据发送给恶意服务器;恶意服务器拿到用户的Cookie信息之后,就可以在其他电脑上模拟用户的登录,然后进行转账等操作。可以监听用户行为。恶意JavaScript可以使用“addEventListener”接口来监听键盘事件,比如可以获取用户输入的信用卡等信息,将其发送到恶意服务器。黑客掌握了这些信息之后,又可以做很多违法的事情。可以通过修改DOM伪造假的登录窗口,用来欺骗用户输入用户名和密码等信息。还可以在页面内生成浮窗广告,这些广告会严重地影响用户体验。跨站脚本注入方式:主要有三种:存储型XSS攻击、反射型XSS攻击和基于DOM的XSS攻击1. 存储型XSS攻击首先黑客利用站点漏洞将一段恶意JavaScript代码提交到网站的数据库中;然后用户向网站请求包含了恶意JavaScript脚本的页面;当用户浏览该页面的时候,恶意脚本就会将用户的Cookie信息等数据上传到服务器。2. 反射型XSS攻击在一个反射型XSS攻击过程中,恶意JavaScript脚本属于用户发送给网站请求中的一部分,随后网站又把恶意JavaScript脚本返回给用户。当恶意JavaScript脚本在用户页面中被执行时,黑客就可以利用该脚本做一些恶意操作。需要注意的是,Web服务器不会存储反射型XSS攻击的恶意脚本,这是和存储型XSS攻击不同的地方。3. 基于DOM的XSS攻击基于DOM的XSS攻击是不牵涉到页面Web服务器的。具体来讲,黑客通过各种手段将恶意脚本注入用户的页面中,比如通过网络劫持在页面传输过程中修改HTML页面的内容,这种劫持类型很多,有通过WiFi路由器劫持的,有通过本地恶意软件来劫持的,它们的共同点是在Web资源传输过程或者在用户使用页面的过程中修改Web页面的数据。如何阻止XSS攻击服务器对输入脚本进行过滤或转码充分利用CSP使用httpOnly属性限制输入长度添加验证码防止脚本冒充用户提交危险操作CSRF攻击CSRF英文全称是Cross-site request forgery,又称为“跨站请求伪造”,是指黑客引诱用户打开黑客的网站,在黑客的网站中,利用用户的登录状态发起的跨站请求。简单来讲,CSRF攻击就是黑客利用了用户的登录状态,并通过第三方的站点来做一些坏事。以下是一些常见的csrf攻击方式:自动发起GET请求<img src="https://xxx/sendcoin?user=hacker&number=100">自动发起POST请求 <form id="hacker-form" action="https://xxx/sendcoin" method="POST"> <input type="hidden" name="user" value="hacker"> <input type="hidden" name="number" value="100"> </form> <script> document.getElementById('hacker-form').submit(); </script> 引诱用户点击链接 xml复制代码<div><img width="150" src="http://images.xuejuzi.cn/1612/1_161230185104_1.jpg"></div> <div> <a href="https://xxx/sendcoin?user=hacker&number=100" taget="_blank"> 点击下载美女照片 </a> </div>发起CSRF攻击的三个必要条件:第一个,目标站点一定要有CSRF漏洞;第二个,用户要登录过目标站点,并且在浏览器上保持有该站点的登录状态;第三个,需要用户打开一个第三方站点,可以是黑客的站点,也可以是一些论坛。如何防止CSRF攻击充分利用好Cookie 的 SameSite 属性验证请求的来源站点CSRF Token浏览器网络安全由于http是明文传输,传输过程每一个环节数据都有可能被窃取和篡改,因此使用http进行通信是不安全的。由此诞生了HTTPS。HTTPS在传输层引入了安全层(SSL/TLS),安全层会对整个通信进行加密,传输过程都使用加密后的密文通信,同时引入CA证书,便于浏览器验证服务器。HTTPS安全层和CA证书的引入,保证了传输过程中数据的安全性,同时也能保证通信双方的身份,比如CA的存在,使得黑客无法冒充服务器和浏览器通信。浏览器系统安全浏览器被划分为浏览器内核和渲染内核两个核心模块,其中浏览器内核是由网络进程、浏览器主进程和GPU进程组成的,渲染内核就是渲染进程。由于渲染进程需要执行DOM解析、CSS解析、网络图片解码等操作,如果渲染进程中存在系统级别的漏洞,那么以上操作就有可能让恶意的站点获取到渲染进程的控制权限,进而又获取操作系统的控制权限,这对于用户来说是非常危险的。因为网络资源的内容存在着各种可能性,所以浏览器会默认所有的网络资源都是不可信的,都是不安全的。但谁也不能保证浏览器不存在漏洞,只要出现漏洞,黑客就可以通过网络内容对用户发起攻击。如果下载了一个恶意程序,但是没有执行它,那么恶意程序是不会生效的。同理,浏览器之于网络内容也是如此,浏览器可以安全地下载各种网络资源,但是如果要执行这些网络资源,比如解析HTML、解析CSS、执行JavaScript、图片编解码等操作,就需要非常谨慎了,因为一不小心,黑客就会利用这些操作对含有漏洞的浏览器发起攻击。基于此,需要在渲染进程和操作系统之间建一道墙,即便渲染进程由于存在漏洞被黑客攻击,但由于这道墙,黑客就获取不到渲染进程之外的任何操作权限。将渲染进程和操作系统隔离的这道墙就是安全沙箱。安全沙箱最小的保护单位是进程,并且能限制进程对操作系统资源的访问和修改,这就意味着如果要让安全沙箱应用在某个进程上,那么这个进程必须没有读写操作系统的功能,比如读写本地文件、发起网络请求、调用GPU接口等。有了安全沙箱,就可以将操作系统和渲染进程进行隔离,这样即便渲染进程由于漏洞被攻击,也不会影响到操作系统的。由于渲染进程采用了安全沙箱,所以在渲染进程内部不能与操作系统直接交互,于是就在浏览器内核中实现了持久存储、网络访问和用户交互等一系列与操作系统交互的功能,然后通过IPC和渲染进程进行交互。
0
0
0
浏览量1940
前端码农

宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的

浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第一篇浏览器架构线程:操作系统能够进行运算调度的最小单位。进程:一个进程就是一个程序的运行实例。启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,把这样的一个运行环境叫进程。线程是依附于进程的,而进程中使用多线程并行处理能提升运算效率。进程和线程之间的关系4个特点。进程中任意线程执行出错,都会导致整个进程崩溃线程之间共享进程中的数据当一个进程关闭后,操作系统会回收进程占用的内存进程之间内容相互隔离(通过IPC机制进行通信)现代浏览器为多进程架构,打开一个页面,浏览器至少会打开四个进程 -- 浏览器主进程、渲染进程、GPU进程、网络进程。浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个Tab标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。GPU进程:实现3D CSS效果,UI界面绘制。网络进程。主要负责页面的网络资源加载。插件进程。主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。地址栏键入URL后会发生什么?构建请求:浏览器准备发起网络请求,构建请求行等查找缓存:若命中强缓存,直接拦截请求,从本地获取资源准备IP地址和端口:DNS查询获取IP,端口号一般默认80等待TCP队列:http1.1,浏览器请求限制一般为一个域名最多维持6个请求连接,如果发送网络请求超过6个,则会进入等待对列排队建立TCP连接:3次握手发起HTTP请求:浏览器会向服务器发送请求行,包括了请求方法、请求URI和HTTP版本协议。服务器处理并响应请求:成功返回状态码200,304命中缓存,301/302重定向断开TCP连接:4次挥手渲染流程:HTML、CSS和JavaScript,是如何变成页面的?构建DOM树:浏览器无法直接理解和使用HTML,所以需要将HTML转换为浏览器能够理解的结构——DOM树样式计算: 把CSS转换为浏览器能够理解的结构 -- styleSheets 转换样式表中的属性值,使其标准化,比如将颜色‘red’转化成渲染引擎可以理解的rgb值 计算DOM中每个节点的具体样式,涉及到CSS的继承规则和层叠规则布局阶段:有了DOM树和样式,还不足以显示页面,接下来就需要计算出DOM树中可见元素的几何位置,这个计算过程叫做布局。 创建布局树:构建一颗元素布局树,比如去掉head等没有实际元素的标签,去掉display为none的元素等 布局计算:分层:页面中有很多复杂的效果,如一些复杂的3D变换、页面滚动,或者使用z-indexing做z轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树。所以浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面图层绘制:渲染引擎将每个图层的绘制拆分成很小的绘制指令,再将指令按顺序组成待绘制列表,最后根据该列表绘制图层。栅格化操作:通常一个页面可能很大,但是用户只能看到其中的一部分,这个部分叫做视口。在有些情况下,有的图层可以很大,比如有的页面要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。基于这个原因,合成线程会将图层划分为图块,这些图块的大小通常是256x256或者512x512。然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。合成和显示:所有图块都栅格化完成后,合成线程将指令提交给浏览器进程,浏览器进程根据指令将页面内容绘制到内存中,最后从内存中取出图像显示到屏幕上。
0
0
0
浏览量1888
前端码农

构造函数与 new 命令

对象是什么面向对象编程(Object Oriented Programming,缩写为 OOP)是目前主流的编程范式。它将真实世界各种复杂的关系,抽象为一个个对象,然后由对象之间的分工与合作,完成对真实世界的模拟。每一个对象都是功能中心,具有明确分工,可以完成接受信息、处理数据、发出信息等任务。对象可以复用,通过继承机制还可以定制。因此,面向对象编程具有灵活、代码可复用、高度模块化等特点,容易维护和开发,比起由一系列函数或指令组成的传统的过程式编程(procedural programming),更适合多人合作的大型软件项目。那么,“对象”(object)到底是什么?我们从两个层次来理解。(1)对象是单个实物的抽象。一本书、一辆汽车、一个人都可以是对象,一个数据库、一张网页、一个与远程服务器的连接也可以是对象。当实物被抽象成对象,实物之间的关系就变成了对象之间的关系,从而就可以模拟现实情况,针对对象进行编程。(2)对象是一个容器,封装了属性(property)和方法(method)。属性是对象的状态,方法是对象的行为(完成某种任务)。比如,我们可以把动物抽象为animal对象,使用“属性”记录具体是那一种动物,使用“方法”表示动物的某种行为(奔跑、捕猎、休息等等)。构造函数面向对象编程的第一步,就是要生成对象。前面说过,对象是单个实物的抽象。通常需要一个模板,表示某一类实物的共同特征,然后对象根据这个模板生成。典型的面向对象编程语言(比如 C++ 和 Java),都有“类”(class)这个概念。所谓“类”就是对象的模板,对象就是“类”的实例。但是,JavaScript 语言的对象体系,不是基于“类”的,而是基于构造函数(constructor)和原型链(prototype)。JavaScript 语言使用构造函数(constructor)作为对象的模板。所谓”构造函数”,就是专门用来生成实例对象的函数。它就是对象的模板,描述实例对象的基本结构。一个构造函数,可以生成多个实例对象,这些实例对象都有相同的结构。构造函数就是一个普通的函数,但是有自己的特征和用法。var Vehicle = function () { this.price = 1000; };上面代码中,Vehicle就是构造函数。为了与普通函数区别,构造函数名字的第一个字母通常大写。构造函数的特点有两个。函数体内部使用了this关键字,代表了所要生成的对象实例。生成对象的时候,必须使用new命令。下面先介绍new命令。new 命令基本用法new命令的作用,就是执行构造函数,返回一个实例对象。var Vehicle = function () { this.price = 1000; }; var v = new Vehicle(); v.price // 1000上面代码通过new命令,让构造函数Vehicle生成一个实例对象,保存在变量v中。这个新生成的实例对象,从构造函数Vehicle得到了price属性。new命令执行时,构造函数内部的this,就代表了新生成的实例对象,this.price表示实例对象有一个price属性,值是1000。使用new命令时,根据需要,构造函数也可以接受参数。var Vehicle = function (p) { this.price = p; }; var v = new Vehicle(500);new命令本身就可以执行构造函数,所以后面的构造函数可以带括号,也可以不带括号。下面两行代码是等价的,但是为了表示这里是函数调用,推荐使用括号。// 推荐的写法 var v = new Vehicle(); // 不推荐的写法 var v = new Vehicle;一个很自然的问题是,如果忘了使用new命令,直接调用构造函数会发生什么事?这种情况下,构造函数就变成了普通函数,并不会生成实例对象。而且由于后面会说到的原因,this这时代表全局对象,将造成一些意想不到的结果。var Vehicle = function (){ this.price = 1000; }; var v = Vehicle(); v // undefined price // 1000上面代码中,调用Vehicle构造函数时,忘了加上new命令。结果,变量v变成了undefined,而price属性变成了全局变量。因此,应该非常小心,避免不使用new命令、直接调用构造函数。为了保证构造函数必须与new命令一起使用,一个解决办法是,构造函数内部使用严格模式,即第一行加上use strict。这样的话,一旦忘了使用new命令,直接调用构造函数就会报错。function Fubar(foo, bar){ 'use strict'; this._foo = foo; this._bar = bar; } Fubar() // TypeError: Cannot set property '_foo' of undefined上面代码的Fubar为构造函数,use strict命令保证了该函数在严格模式下运行。由于严格模式中,函数内部的this不能指向全局对象,默认等于undefined,导致不加new调用会报错(JavaScript 不允许对undefined添加属性)。另一个解决办法,构造函数内部判断是否使用new命令,如果发现没有使用,则直接返回一个实例对象。function Fubar(foo, bar) { if (!(this instanceof Fubar)) { return new Fubar(foo, bar); } this._foo = foo; this._bar = bar; } Fubar(1, 2)._foo // 1 (new Fubar(1, 2))._foo // 1上面代码中的构造函数,不管加不加new命令,都会得到同样的结果。new 命令的原理使用new命令时,它后面的函数依次执行下面的步骤。创建一个空对象,作为将要返回的对象实例。将这个空对象的原型,指向构造函数的prototype属性。将这个空对象赋值给函数内部的this关键字。开始执行构造函数内部的代码。也就是说,构造函数内部,this指的是一个新生成的空对象,所有针对this的操作,都会发生在这个空对象上。构造函数之所以叫“构造函数”,就是说这个函数的目的,就是操作一个空对象(即this对象),将其“构造”为需要的样子。如果构造函数内部有return语句,而且return后面跟着一个对象,new命令会返回return语句指定的对象;否则,就会不管return语句,返回this对象。var Vehicle = function () { this.price = 1000; return 1000; }; (new Vehicle()) === 1000 // false上面代码中,构造函数Vehicle的return语句返回一个数值。这时,new命令就会忽略这个return语句,返回“构造”后的this对象。但是,如果return语句返回的是一个跟this无关的新对象,new命令会返回这个新对象,而不是this对象。这一点需要特别引起注意。var Vehicle = function (){ this.price = 1000; return { price: 2000 }; }; (new Vehicle()).price // 2000上面代码中,构造函数Vehicle的return语句,返回的是一个新对象。new命令会返回这个对象,而不是this对象。另一方面,如果对普通函数(内部没有this关键字的函数)使用new命令,则会返回一个空对象。function getMessage() { return 'this is a message'; } var msg = new getMessage(); msg // {} typeof msg // "object"上面代码中,getMessage是一个普通函数,返回一个字符串。对它使用new命令,会得到一个空对象。这是因为new命令总是返回一个对象,要么是实例对象,要么是return语句指定的对象。本例中,return语句返回的是字符串,所以new命令就忽略了该语句。new命令简化的内部流程,可以用下面的代码表示。function _new(/* 构造函数 */ constructor, /* 构造函数参数 */ params) { // 将 arguments 对象转为数组 var args = [].slice.call(arguments); // 取出构造函数 var constructor = args.shift(); // 创建一个空对象,继承构造函数的 prototype 属性 var context = Object.create(constructor.prototype); // 执行构造函数 var result = constructor.apply(context, args); // 如果返回结果是对象,就直接返回,否则返回 context 对象 return (typeof result === 'object' && result != null) ? result : context; } // 实例 var actor = _new(Person, '张三', 28);new.target函数内部可以使用new.target属性。如果当前函数是new命令调用,new.target指向当前函数,否则为undefined。function f() { console.log(new.target === f); } f() // false new f() // true使用这个属性,可以判断函数调用的时候,是否使用new命令。function f() { if (!new.target) { throw new Error('请使用 new 命令调用!'); } // ... } f() // Uncaught Error: 请使用 new 命令调用!上面代码中,构造函数f调用时,没有使用new命令,就抛出一个错误。Object.create() 创建实例对象构造函数作为模板,可以生成实例对象。但是,有时拿不到构造函数,只能拿到一个现有的对象。我们希望以这个现有的对象作为模板,生成新的实例对象,这时就可以使用Object.create()方法。var person1 = { name: '张三', age: 38, greeting: function() { console.log('Hi! I'm ' + this.name + '.'); } }; var person2 = Object.create(person1); person2.name // 张三 person2.greeting() // Hi! I'm 张三.上面代码中,对象person1是person2的模板,后者继承了前者的属性和方法。
0
0
0
浏览量370
前端码农

Object 对象的相关方法介绍

Object.getPrototypeOf()Object.getPrototypeOf方法返回参数对象的原型。这是获取原型对象的标准方法。var F = function () {}; var f = new F(); Object.getPrototypeOf(f) === F.prototype // true上面代码中,实例对象f的原型是F.prototype。下面是几种特殊对象的原型。// 空对象的原型是 Object.prototype Object.getPrototypeOf({}) === Object.prototype // true // Object.prototype 的原型是 null Object.getPrototypeOf(Object.prototype) === null // true // 函数的原型是 Function.prototype function f() {} Object.getPrototypeOf(f) === Function.prototype // trueObject.setPrototypeOf()Object.setPrototypeOf方法为参数对象设置原型,返回该参数对象。它接受两个参数,第一个是现有对象,第二个是原型对象。var a = {}; var b = {x: 1}; Object.setPrototypeOf(a, b); Object.getPrototypeOf(a) === b // true a.x // 1上面代码中,Object.setPrototypeOf方法将对象a的原型,设置为对象b,因此a可以共享b的属性。new命令可以使用Object.setPrototypeOf方法模拟。var F = function () { this.foo = 'bar'; }; var f = new F(); // 等同于 var f = Object.setPrototypeOf({}, F.prototype); F.call(f);上面代码中,new命令新建实例对象,其实可以分成两步。第一步,将一个空对象的原型设为构造函数的prototype属性(上例是F.prototype);第二步,将构造函数内部的this绑定这个空对象,然后执行构造函数,使得定义在this上面的方法和属性(上例是this.foo),都转移到这个空对象上。Object.create()生成实例对象的常用方法是,使用new命令让构造函数返回一个实例。但是很多时候,只能拿到一个实例对象,它可能根本不是由构建函数生成的,那么能不能从一个实例对象,生成另一个实例对象呢?JavaScript 提供了Object.create方法,用来满足这种需求。该方法接受一个对象作为参数,然后以它为原型,返回一个实例对象。该实例完全继承原型对象的属性。// 原型对象 var A = { print: function () { console.log('hello'); } }; // 实例对象 var B = Object.create(A); Object.getPrototypeOf(B) === A // true B.print() // hello B.print === A.print // true上面代码中,Object.create方法以A对象为原型,生成了B对象。B继承了A的所有属性和方法。实际上,Object.create方法可以用下面的代码代替。if (typeof Object.create !== 'function') { Object.create = function (obj) { function F() {} F.prototype = obj; return new F(); }; }上面代码表明,Object.create方法的实质是新建一个空的构造函数F,然后让F.prototype属性指向参数对象obj,最后返回一个F的实例,从而实现让该实例继承obj的属性。下面三种方式生成的新对象是等价的。var obj1 = Object.create({}); var obj2 = Object.create(Object.prototype); var obj3 = new Object();如果想要生成一个不继承任何属性(比如没有toString和valueOf方法)的对象,可以将Object.create的参数设为null。var obj = Object.create(null); obj.valueOf() // TypeError: Object [object Object] has no method 'valueOf'上面代码中,对象obj的原型是null,它就不具备一些定义在Object.prototype对象上面的属性,比如valueOf方法。使用Object.create方法的时候,必须提供对象原型,即参数不能为空,或者不是对象,否则会报错。Object.create() // TypeError: Object prototype may only be an Object or null Object.create(123) // TypeError: Object prototype may only be an Object or nullObject.create方法生成的新对象,动态继承了原型。在原型上添加或修改任何方法,会立刻反映在新对象之上。var obj1 = { p: 1 }; var obj2 = Object.create(obj1); obj1.p = 2; obj2.p // 2上面代码中,修改对象原型obj1会影响到实例对象obj2。除了对象的原型,Object.create方法还可以接受第二个参数。该参数是一个属性描述对象,它所描述的对象属性,会添加到实例对象,作为该对象自身的属性。var obj = Object.create({}, { p1: { value: 123, enumerable: true, configurable: true, writable: true, }, p2: { value: 'abc', enumerable: true, configurable: true, writable: true, } }); // 等同于 var obj = Object.create({}); obj.p1 = 123; obj.p2 = 'abc';Object.create方法生成的对象,继承了它的原型对象的构造函数。function A() {} var a = new A(); var b = Object.create(a); b.constructor === A // true b instanceof A // true上面代码中,b对象的原型是a对象,因此继承了a对象的构造函数A。Object.prototype.isPrototypeOf()实例对象的isPrototypeOf方法,用来判断该对象是否为参数对象的原型。var o1 = {}; var o2 = Object.create(o1); var o3 = Object.create(o2); o2.isPrototypeOf(o3) // true o1.isPrototypeOf(o3) // true上面代码中,o1和o2都是o3的原型。这表明只要实例对象处在参数对象的原型链上,isPrototypeOf方法都返回true。Object.prototype.isPrototypeOf({}) // true Object.prototype.isPrototypeOf([]) // true Object.prototype.isPrototypeOf(/xyz/) // true Object.prototype.isPrototypeOf(Object.create(null)) // false上面代码中,由于Object.prototype处于原型链的最顶端,所以对各种实例都返回true,只有直接继承自null的对象除外。Object.prototype.proto实例对象的__proto__属性(前后各两个下划线),返回该对象的原型。该属性可读写。var obj = {}; var p = {}; obj.__proto__ = p; Object.getPrototypeOf(obj) === p // true上面代码通过__proto__属性,将p对象设为obj对象的原型。根据语言标准,__proto__属性只有浏览器才需要部署,其他环境可以没有这个属性。它前后的两根下划线,表明它本质是一个内部属性,不应该对使用者暴露。因此,应该尽量少用这个属性,而是用Object.getPrototypeof()和Object.setPrototypeOf(),进行原型对象的读写操作。原型链可以用__proto__很直观地表示。var A = { name: '张三' }; var B = { name: '李四' }; var proto = { print: function () { console.log(this.name); } }; A.__proto__ = proto; B.__proto__ = proto; A.print() // 张三 B.print() // 李四 A.print === B.print // true A.print === proto.print // true B.print === proto.print // true上面代码中,A对象和B对象的原型都是proto对象,它们都共享proto对象的print方法。也就是说,A和B的print方法,都是在调用proto对象的print方法。获取原型对象方法的比较如前所述,__proto__属性指向当前对象的原型对象,即构造函数的prototype属性。var obj = new Object(); obj.__proto__ === Object.prototype // true obj.__proto__ === obj.constructor.prototype // true上面代码首先新建了一个对象obj,它的__proto__属性,指向构造函数(Object或obj.constructor)的prototype属性。因此,获取实例对象obj的原型对象,有三种方法。obj.__proto__obj.constructor.prototypeObject.getPrototypeOf(obj)上面三种方法之中,前两种都不是很可靠。__proto__属性只有浏览器才需要部署,其他环境可以不部署。而obj.constructor.prototype在手动改变原型对象时,可能会失效。var P = function () {}; var p = new P(); var C = function () {}; C.prototype = p; var c = new C(); c.constructor.prototype === p // false上面代码中,构造函数C的原型对象被改成了p,但是实例对象的c.constructor.prototype却没有指向p。所以,在改变原型对象时,一般要同时设置constructor属性。C.prototype = p; C.prototype.constructor = C; var c = new C(); c.constructor.prototype === p // true因此,推荐使用第三种Object.getPrototypeOf方法,获取原型对象。Object.getOwnPropertyNames()Object.getOwnPropertyNames方法返回一个数组,成员是参数对象本身的所有属性的键名,不包含继承的属性键名。Object.getOwnPropertyNames(Date) // ["parse", "arguments", "UTC", "caller", "name", "prototype", "now", "length"]上面代码中,Object.getOwnPropertyNames方法返回Date所有自身的属性名。对象本身的属性之中,有的是可以遍历的(enumerable),有的是不可以遍历的。Object.getOwnPropertyNames方法返回所有键名,不管是否可以遍历。只获取那些可以遍历的属性,使用Object.keys方法。Object.keys(Date) // []上面代码表明,Date对象所有自身的属性,都是不可以遍历的。Object.prototype.hasOwnProperty()对象实例的hasOwnProperty方法返回一个布尔值,用于判断某个属性定义在对象自身,还是定义在原型链上。Date.hasOwnProperty('length') // true Date.hasOwnProperty('toString') // false上面代码表明,Date.length(构造函数Date可以接受多少个参数)是Date自身的属性,Date.toString是继承的属性。另外,hasOwnProperty方法是 JavaScript 之中唯一一个处理对象属性时,不会遍历原型链的方法。in 运算符和 for…in 循环in运算符返回一个布尔值,表示一个对象是否具有某个属性。它不区分该属性是对象自身的属性,还是继承的属性。'length' in Date // true 'toString' in Date // truein运算符常用于检查一个属性是否存在。获得对象的所有可遍历属性(不管是自身的还是继承的),可以使用for...in循环。var o1 = { p1: 123 }; var o2 = Object.create(o1, { p2: { value: "abc", enumerable: true } }); for (p in o2) { console.info(p); } // p2 // p1上面对象中,对象o2的p2属性是自身的,p1属性是继承的。这两个属性都会被for...in循环遍历。为了在for...in循环中获得对象自身的属性,可以采用hasOwnProperty方法判断一下。for ( var name in object ) { if ( object.hasOwnProperty(name) ) { /* loop code */ } }获得对象的所有属性(不管是自身的还是继承的,也不管是否可枚举),可以使用下面的函数。function inheritedPropertyNames(obj) { var props = {}; while(obj) { Object.getOwnPropertyNames(obj).forEach(function(p) { props[p] = true; }); obj = Object.getPrototypeOf(obj); } return Object.getOwnPropertyNames(props); }上面代码依次获取obj对象的每一级原型对象“自身”的属性,从而获取obj对象的“所有”属性,不管是否可遍历。下面是一个例子,列出Date对象的所有属性。inheritedPropertyNames(Date) // [ // "caller", // "constructor", // "toString", // "UTC", // ... // ]对象的拷贝如果要拷贝一个对象,需要做到下面两件事情。确保拷贝后的对象,与原对象具有同样的原型。确保拷贝后的对象,与原对象具有同样的实例属性。下面就是根据上面两点,实现的对象拷贝函数。function copyObject(orig) { var copy = Object.create(Object.getPrototypeOf(orig)); copyOwnPropertiesFrom(copy, orig); return copy; } function copyOwnPropertiesFrom(target, source) { Object .getOwnPropertyNames(source) .forEach(function (propKey) { var desc = Object.getOwnPropertyDescriptor(source, propKey); Object.defineProperty(target, propKey, desc); }); return target; }另一种更简单的写法,是利用 ES2017 才引入标准的Object.getOwnPropertyDescriptors方法。function copyObject(orig) { return Object.create( Object.getPrototypeOf(orig), Object.getOwnPropertyDescriptors(orig) ); }
0
0
0
浏览量2012
前端码农

prototype 对象的详细介绍

原型对象概述构造函数的缺点JavaScript 通过构造函数生成新对象,因此构造函数可以视为对象的模板。实例对象的属性和方法,可以定义在构造函数内部。function Cat (name, color) { this.name = name; this.color = color; } var cat1 = new Cat('大毛', '白色'); cat1.name // '大毛' cat1.color // '白色'上面代码中,Cat函数是一个构造函数,函数内部定义了name属性和color属性,所有实例对象(上例是cat1)都会生成这两个属性,即这两个属性会定义在实例对象上面。通过构造函数为实例对象定义属性,虽然很方便,但是有一个缺点。同一个构造函数的多个实例之间,无法共享属性,从而造成对系统资源的浪费。function Cat(name, color) { this.name = name; this.color = color; this.meow = function () { console.log('喵喵'); }; } var cat1 = new Cat('大毛', '白色'); var cat2 = new Cat('二毛', '黑色'); cat1.meow === cat2.meow // false上面代码中,cat1和cat2是同一个构造函数的两个实例,它们都具有meow方法。由于meow方法是生成在每个实例对象上面,所以两个实例就生成了两次。也就是说,每新建一个实例,就会新建一个meow方法。这既没有必要,又浪费系统资源,因为所有meow方法都是同样的行为,完全应该共享。这个问题的解决方法,就是 JavaScript 的原型对象(prototype)。prototype 属性的作用JavaScript 继承机制的设计思想就是,原型对象的所有属性和方法,都能被实例对象共享。也就是说,如果属性和方法定义在原型上,那么所有实例对象就能共享,不仅节省了内存,还体现了实例对象之间的联系。下面,先看怎么为对象指定原型。JavaScript 规定,每个函数都有一个prototype属性,指向一个对象。function f() {} typeof f.prototype // "object"上面代码中,函数f默认具有prototype属性,指向一个对象。对于普通函数来说,该属性基本无用。但是,对于构造函数来说,生成实例的时候,该属性会自动成为实例对象的原型。function Animal(name) { this.name = name; } Animal.prototype.color = 'white'; var cat1 = new Animal('大毛'); var cat2 = new Animal('二毛'); cat1.color // 'white' cat2.color // 'white'上面代码中,构造函数Animal的prototype属性,就是实例对象cat1和cat2的原型对象。原型对象上添加一个color属性,结果,实例对象都共享了该属性。原型对象的属性不是实例对象自身的属性。只要修改原型对象,变动就立刻会体现在所有实例对象上。Animal.prototype.color = 'yellow'; cat1.color // "yellow" cat2.color // "yellow"上面代码中,原型对象的color属性的值变为yellow,两个实例对象的color属性立刻跟着变了。这是因为实例对象其实没有color属性,都是读取原型对象的color属性。也就是说,当实例对象本身没有某个属性或方法的时候,它会到原型对象去寻找该属性或方法。这就是原型对象的特殊之处。如果实例对象自身就有某个属性或方法,它就不会再去原型对象寻找这个属性或方法。cat1.color = 'black'; cat1.color // 'black' cat2.color // 'yellow' Animal.prototype.color // 'yellow';上面代码中,实例对象cat1的color属性改为black,就使得它不再去原型对象读取color属性,后者的值依然为yellow。总结一下,原型对象的作用,就是定义所有实例对象共享的属性和方法。这也是它被称为原型对象的原因,而实例对象可以视作从原型对象衍生出来的子对象。Animal.prototype.walk = function () { console.log(this.name + ' is walking'); };上面代码中,Animal.prototype对象上面定义了一个walk方法,这个方法将可以在所有Animal实例对象上面调用。原型链JavaScript 规定,所有对象都有自己的原型对象(prototype)。一方面,任何一个对象,都可以充当其他对象的原型;另一方面,由于原型对象也是对象,所以它也有自己的原型。因此,就会形成一个“原型链”(prototype chain):对象到原型,再到原型的原型……如果一层层地上溯,所有对象的原型最终都可以上溯到Object.prototype,即Object构造函数的prototype属性。也就是说,所有对象都继承了Object.prototype的属性。这就是所有对象都有valueOf和toString方法的原因,因为这是从Object.prototype继承的。那么,Object.prototype对象有没有它的原型呢?回答是Object.prototype的原型是null。null没有任何属性和方法,也没有自己的原型。因此,原型链的尽头就是null。Object.getPrototypeOf(Object.prototype) // null上面代码表示,Object.prototype对象的原型是null,由于null没有任何属性,所以原型链到此为止。Object.getPrototypeOf方法返回参数对象的原型,具体介绍请看后文。读取对象的某个属性时,JavaScript 引擎先寻找对象本身的属性,如果找不到,就到它的原型去找,如果还是找不到,就到原型的原型去找。如果直到最顶层的Object.prototype还是找不到,则返回undefined。如果对象自身和它的原型,都定义了一个同名属性,那么优先读取对象自身的属性,这叫做“覆盖”(overriding)。注意,一级级向上,在整个原型链上寻找某个属性,对性能是有影响的。所寻找的属性在越上层的原型对象,对性能的影响越大。如果寻找某个不存在的属性,将会遍历整个原型链。举例来说,如果让构造函数的prototype属性指向一个数组,就意味着实例对象可以调用数组方法。var MyArray = function () {}; MyArray.prototype = new Array(); MyArray.prototype.constructor = MyArray; var mine = new MyArray(); mine.push(1, 2, 3); mine.length // 3 mine instanceof Array // true上面代码中,mine是构造函数MyArray的实例对象,由于MyArray.prototype指向一个数组实例,使得mine可以调用数组方法(这些方法定义在数组实例的prototype对象上面)。最后那行instanceof表达式,用来比较一个对象是否为某个构造函数的实例,结果就是证明mine为Array的实例,instanceof运算符的详细解释详见后文。上面代码还出现了原型对象的contructor属性,这个属性的含义下一节就来解释。constructor 属性prototype对象有一个constructor属性,默认指向prototype对象所在的构造函数。function P() {} P.prototype.constructor === P // true由于constructor属性定义在prototype对象上面,意味着可以被所有实例对象继承。function P() {} var p = new P(); p.constructor === P // true p.constructor === P.prototype.constructor // true p.hasOwnProperty('constructor') // false上面代码中,p是构造函数P的实例对象,但是p自身没有constructor属性,该属性其实是读取原型链上面的P.prototype.constructor属性。constructor属性的作用是,可以得知某个实例对象,到底是哪一个构造函数产生的。function F() {}; var f = new F(); f.constructor === F // true f.constructor === RegExp // false上面代码中,constructor属性确定了实例对象f的构造函数是F,而不是RegExp。另一方面,有了constructor属性,就可以从一个实例对象新建另一个实例。function Constr() {} var x = new Constr(); var y = new x.constructor(); y instanceof Constr // true上面代码中,x是构造函数Constr的实例,可以从x.constructor间接调用构造函数。这使得在实例方法中,调用自身的构造函数成为可能。Constr.prototype.createCopy = function () { return new this.constructor(); };上面代码中,createCopy方法调用构造函数,新建另一个实例。constructor属性表示原型对象与构造函数之间的关联关系,如果修改了原型对象,一般会同时修改constructor属性,防止引用的时候出错。function Person(name) { this.name = name; } Person.prototype.constructor === Person // true Person.prototype = { method: function () {} }; Person.prototype.constructor === Person // false Person.prototype.constructor === Object // true上面代码中,构造函数Person的原型对象改掉了,但是没有修改constructor属性,导致这个属性不再指向Person。由于Person的新原型是一个普通对象,而普通对象的contructor属性指向Object构造函数,导致Person.prototype.constructor变成了Object。所以,修改原型对象时,一般要同时修改constructor属性的指向。// 坏的写法 C.prototype = { method1: function (...) { ... }, // ... }; // 好的写法 C.prototype = { constructor: C, method1: function (...) { ... }, // ... }; // 更好的写法 C.prototype.method1 = function (...) { ... };上面代码中,要么将constructor属性重新指向原来的构造函数,要么只在原型对象上添加方法,这样可以保证instanceof运算符不会失真。如果不能确定constructor属性是什么函数,还有一个办法:通过name属性,从实例得到构造函数的名称。function Foo() {} var f = new Foo(); f.constructor.name // "Foo"instanceof 运算符instanceof运算符返回一个布尔值,表示对象是否为某个构造函数的实例。var v = new Vehicle(); v instanceof Vehicle // true上面代码中,对象v是构造函数Vehicle的实例,所以返回true。instanceof运算符的左边是实例对象,右边是构造函数。它会检查右边构建函数的原型对象(prototype),是否在左边对象的原型链上。因此,下面两种写法是等价的。v instanceof Vehicle // 等同于 Vehicle.prototype.isPrototypeOf(v)上面代码中,Object.prototype.isPrototypeOf的详细解释见后文。由于instanceof检查整个原型链,因此同一个实例对象,可能会对多个构造函数都返回true。var d = new Date(); d instanceof Date // true d instanceof Object // true上面代码中,d同时是Date和Object的实例,因此对这两个构造函数都返回true。instanceof的原理是检查右边构造函数的prototype属性,是否在左边对象的原型链上。有一种特殊情况,就是左边对象的原型链上,只有null对象。这时,instanceof判断会失真。var obj = Object.create(null); typeof obj // "object" Object.create(null) instanceof Object // false上面代码中,Object.create(null)返回一个新对象obj,它的原型是null(Object.create的详细介绍见后文)。右边的构造函数Object的prototype属性,不在左边的原型链上,因此instanceof就认为obj不是Object的实例。但是,只要一个对象的原型不是null,instanceof运算符的判断就不会失真。instanceof运算符的一个用处,是判断值的类型。var x = [1, 2, 3]; var y = {}; x instanceof Array // true y instanceof Object // true上面代码中,instanceof运算符判断,变量x是数组,变量y是对象。注意,instanceof运算符只能用于对象,不适用原始类型的值。var s = 'hello'; s instanceof String // false上面代码中,字符串不是String对象的实例(因为字符串不是对象),所以返回false。此外,对于undefined和null,instanceOf运算符总是返回false。undefined instanceof Object // false null instanceof Object // false利用instanceof运算符,还可以巧妙地解决,调用构造函数时,忘了加new命令的问题。function Fubar (foo, bar) { if (this instanceof Fubar) { this._foo = foo; this._bar = bar; } else { return new Fubar(foo, bar); } }上面代码使用instanceof运算符,在函数体内部判断this关键字是否为构造函数Fubar的实例。如果不是,就表明忘了加new命令。
0
0
0
浏览量138
前端码农

浏览器中的页面循环系统:宏任务微任务如何有条不紊的执行?

浏览器原理与实践系列文章内容来自极客时间李兵老师《浏览器工作原理与实践》,主要是记录自己的学习过程,基于自己的理解对内容做的一些总结,包括《宏观视角下的浏览器:地址栏键入URL后会发生什么?HTML,CSS,JS又是怎么变成页面显示出来的》《JS中内存机制、数据类型、V8引擎垃圾回收原理、V8怎样执行JS代码,据此可以做哪些性能优化》《V8引擎工作原理》《事件循环系统:宏任务微任务如何有条不紊的执行?》《浏览器中的页面:重绘重排及合成?如何提高页面渲染性能》《网络协议:http1、http2、http3都有些什么优缺点?》《浏览器安全:xss及CSRF攻击有何特点?如何防御》共七篇,此为第四篇消息队列和事件循环每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理DOM,又要计算样式,还要处理布局,同时还需要处理JavaScript任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,即消息队列和事件循环系统。消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取。IO线程中产生的新任务添加进消息队列尾部;渲染主线程会循环地从消息队列头部中读取任务,执行任务。渲染进程还有一个IO线程专门用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。任务类型有很多,包括内部消息类型,如输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript定时器等等。除此之外,消息队列中还包含了很多与页面相关的事件,如JavaScript执行、解析DOM、样式计算、布局计算、CSS动画等。如何处理高优先级的任务。比如一个典型的场景是监控DOM节点的变化情况(节点的插入、修改、删除等动态变化),然后根据这些变化来处理相应的业务逻辑。一个通用的设计的是,利用JavaScript设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。不过这个模式有个问题,因为DOM变化非常频繁,如果每次发生变化的时候,都直接调用相应的JavaScript接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。如果将这些DOM变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。这也就是说,如果DOM发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性。为了权衡效率和实时性,微任务就应用而生了。事件循环:消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果DOM(微任务)有变化,那么就会将该变化添加到微任务列表中,这样就不会影响到宏任务的继续执行,就解决了执行效率的问题。等宏任务中的主要功能都直接完成之后,渲染引擎并不着急去执行下一个宏任务,而是执行当前宏任务中的微任务,因为DOM变化的事件都保存在这些微任务队列中,这就解决了实时性问题。 如此循环执行宏任务,再执行清空相应的微任务队列,便是事件循环系统。浏览器是如何实现setTimeout的渲染进程中所有运行在主线程上的任务都需要先添加到消息队列,然后事件循环系统再按照顺序执行消息队列中的任务。所以说要执行一段异步任务,需要先将任务添加到消息队列中。不过通过定时器设置回调函数有点特别,它们需要在指定的时间间隔内被调用,但消息队列中的任务是按照顺序执行的,所以为了保证回调函数能在指定时间内执行,不能将定时器的回调函数直接添加到消息队列中。在Chrome中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和Chromium内部一些需要延迟执行的任务。所以当通过JavaScript创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。主线程处理完消息队列中的一个任务之后,会执行一个专门处理异步任务的函数,该函数根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程。通过这样的方式,一个完整的定时器就实现了。使用setTimeout的注意事项如果当前任务执行时间过久,会影响定时器任务的执行。通过setTimeout设置的回调任务被放入了消息队列中并且等待下一次执行,这里并不是立即执行的;要执行消息队列中的下个任务,需要等待当前的任务执行完成。如果setTimeout存在嵌套调用,那么系统会设置最短时间间隔为4毫秒 scss复制代码function cb() { setTimeout(cb, 0); } setTimeout(cb, 0);未激活的页面,setTimeout执行最小间隔是1000毫秒。如果标签不是当前的激活标签,那么定时器最小的时间间隔是1000毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量延时执行时间有最大值。Chrome、Safari、Firefox都是以32个bit来存储延时值的,32bit最大只能存放的数字是2147483647毫秒,这就意味着,如果setTimeout设置的延迟值大于 2147483647毫秒(大约24.8天)时就会溢出,那么相当于延时值被设置为0了,这导致定时器会被立即执行使用setTimeout设置的回调函数中的this不符合直觉。指向全局window。宏任务和微任务由消息队列和事件循环系统的工作方式可知,消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求,所以出现了微任务。微任务可以在实时性和效率之间做一个有效的权衡。页面中的大部分任务都是在主线程上执行的,这些任务包括了:渲染事件(如解析DOM、计算布局、绘制);用户交互事件(如鼠标点击、滚动页面、放大缩小等);JavaScript脚本执行事件;网络请求完成、文件读写完成事件。为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个for循环,不断地从这些任务队列中取出任务并执行任务。这些消息队列中的任务称为宏任务。微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。当JavaScript执行一段脚本的时候,V8会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8引擎也会在内部创建一个微任务队列。这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给V8引擎内部使用的,所以无法通过JavaScript直接访问。也就是说每个宏任务都关联了一个微任务队列。微任务产生的时机:第一种方式是使用MutationObserver监控某个DOM节点,然后再通过JavaScript来修改这个节点,或者为这个节点添加、删除部分子节点,当DOM节点发生变化时,就会产生DOM变化记录的微任务。第二种方式是使用Promise,当调用Promise.resolve()或者Promise.reject()的时候,也会产生微任务。执行微任务队列的时机:通常情况下,在当前宏任务中的JavaScript快执行完成时,也就在JavaScript引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。有以下几个结论:微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了100个微任务,执行每个微任务的时间是10毫秒,那么执行这100个微任务的时间就是1000毫秒,也可以说这100个微任务让宏任务的执行时间延长了1000毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。Promisepromise出现为了解决两个问题:消灭嵌套调用(回调地狱)和 合并多个任务的错误处理消灭嵌套调用:首先,Promise实现了回调函数的延时绑定。回调函数的延时绑定在代码上体现就是先创建Promise对象x1,通过Promise的构造函数executor来执行业务逻辑;创建好Promise对象x1之后,再使用x1.then来设置回调函数。其次,需要将回调函数onResolve的返回值穿透到最外层。根据onResolve函数的传入值来决定创建什么类型的Promise任务,创建好的Promise对象需要返回到最外层,这样就可以摆脱嵌套循环了。批量处理异常:Promise对象的错误具有“冒泡”性质,会一直向后传递,直到被onReject函数处理或catch语句捕获为止。具备了这样“冒泡”的特性后,就不需要在每个Promise对象中单独捕获异常了。async/await作用及其实现原理ES7 引入了async/await,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。也支持try catch来捕获异常。生成器 VS 协程:生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的function* genDemo() { console.log("开始执行第一段") yield 'generator 2' console.log("开始执行第二段") yield 'generator 2' console.log("开始执行第三段") yield 'generator 2' console.log("执行结束") return 'generator 2' } console.log('main 0') let gen = genDemo() console.log(gen.next().value) console.log('main 1') console.log(gen.next().value) console.log('main 2') console.log(gen.next().value) console.log('main 3') console.log(gen.next().value) console.log('main 4')由以上代码可以看出,函数genDemo并不是一次执行完的,全局代码和genDemo函数交替执行。这就是生成器函数的特性,可以暂停执行,也可以恢复执行。生成器函数的具体使用方式:在生成器函数内部执行一段代码,如果遇到yield关键字,那么JavaScript引擎将返回关键字后面的内容给外部,并暂停该函数的执行。外部函数可以通过next方法恢复函数的执行。JavaScript引擎V8是如何实现一个函数的暂停和恢复: 要搞懂函数为何能暂停和恢复,首先要了解协程的概念。协程是一种比线程更加轻量级的存在。可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是A协程,要启动B协程,那么A协程就需要将主线程的控制权交给B协程,这就体现在A协程暂停执行,B协程恢复执行;同样,也可以从B协程中启动A协程。通常,如果从A协程启动B协程,就把A协程称为B协程的父协程。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。下图是以上代码的执行流程图:从图中可以看出来协程的四点规则:通过调用生成器函数genDemo来创建一个协程gen,创建之后,gen协程并没有立即执行。要让gen协程执行,需要通过调用gen.next。当协程正在执行的时候,可以通过yield关键字来暂停gen协程的执行,并返回主要信息给父协程。如果协程在执行期间,遇到了return关键字,那么JavaScript引擎会结束当前协程,并将return后面的内容返回给父协程。async/awaitasync是一个通过异步执行并隐式返回 Promise 作为结果的函数。执行到await时会发生什么:async函数返回的是一个Promise对象,结合以下这段代码来看看await到底是什么:async function foo() { console.log(1) let a = await 100 console.log(a) console.log(2) } console.log(0) foo() console.log(3)首先,执行console.log(0)这个语句,打印出来0。紧接着就是执行foo函数,由于foo函数是被async标记过的,所以当进入该函数的时候,JavaScript引擎会保存当前的调用栈等信息,然后执行foo函数中的console.log(1)语句,并打印出1。当执行到await 100时,会默认创建一个Promise对象,代码如下所示:let promise_ = new Promise((resolve,reject){ resolve(100) })在这个promise_对象创建的过程中,在executor函数中调用了resolve函数,JavaScript引擎会将该任务提交给微任务队列。然后JavaScript引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将promise_对象返回给父协程。主线程的控制权已经交给父协程了,然后父协程调用promise_.then来监控promise状态的改变。接下来继续执行父协程的流程,执行console.log(3)。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)的任务等待执行,执行到这里的时候,会触发promise_.then中的回调函数,该回调函数被激活以后,会将主线程的控制权交给foo函数的协程,并同时将value值传给该协程。foo协程激活之后,会把promise返回的的value值赋给变量a,然后foo协程继续执行后续语句,执行完成之后,将控制权归还给父协程。以上就是await/async的执行流程。
0
0
0
浏览量986
前端码农

JavaScriptJ基础探究

JavaScript 语言具有很强的面向对象编程能力,本章介绍 JavaScript 面向对象编程的基础知识。
0
0
0
浏览量2089
前端码农

浏览器渲染原理与性能优化

如何进行性能优化和讲解浏览器的渲染原理
0
0
0
浏览量2072
前端码农

浏览器与前端性能深度解析

深入了解前端安全,性能优化,网络协议,页面渲染机制,事件循环,内存管理,垃圾回收及浏览器的内部工作原理。
0
0
0
浏览量2116
前端码农

高难度自定义实现小程序精美的slider选择器,泰裤辣

最近在个人开发的小程序中,想要改版设计一个好看点的滑动选择器,因为自带的滑动选择器实在是太太太丑了。自带的小程序slider长这样:经过各种APP探索,最终小红书的【身高体重选择器】映入我的眼帘,它长这样:依葫芦画瓢有了葫芦好画瓢,接下来就看下怎么在小程序中实现同样的效果吧。使用scroll-view组件很显然,这里滚动需要用到scroll-view组件,监听滚动的距离实时计算出对应值。布局实现我们先仔细观察下结构可以看到,该滑动器主要由两部分组成:浮标及指示尺,其中浮标是需要固定位置显示,所以在写布局的时候,我们不能将浮标标签写在scroll-view里面,需要与scroll-view同级通过定位方式显示。另外,由于起始值跟最大值需要可以滚动到浮标位置,所以scroll-view里面的起始跟结尾需要有标签宽度进行占位。指示尺的实现就比较简单,使用盒子+borderRight实现即可。布局代码实现说明:以下代码使用uniapp vue3 setup 写法实现 <view class="relative"> <!-- 浮标尺 --> <view class="pointer-wrap"> <text class="triangle"></text> <text class="slide-num">{{ slideNum }}岁</text> <text class="pointer"></text> </view> <scroll-view :scroll-with-animation="true" :scroll-left="scrollLeft" scroll-x enhanced :show-scrollbar="false" :bounces="true" @scroll="onScroll" class="relative" > <view class="expand-line"> <text class="gap-space"></text> <text class="line-box" :id="'box' + idx" :class="{ higher: idx % 5 === 0 }" v-for="(_, idx) in max - min + 1" > <text class="num" v-if="idx % 5 === 0">{{ min + idx }}</text> </text> <text class="gap-space last"></text> </view> </scroll-view> </view> 其中值得一提的是,隐藏scroll-view滚动条通过配置show-scrollbar:false即可。css样式: <style lang="scss"> .pointer-wrap { position: absolute; z-index: 9; left: 166px; top: 20px; .triangle { position: absolute; top: -10px; left: -4px; width: 0; height: 0; border-top: 5px solid #efaf13; border-bottom: 5px solid white; border-left: 5px solid white; border-right: 5px solid white; } .slide-num { position: absolute; width: 40px; text-align: center; font-size: 14px; color: #efaf13; top: -30px; left: -16px; font-weight: bold; } .pointer { display: inline-block; width: 1px; height: 20px; background-color: #efaf13; } } .expand-line { position: relative; white-space: nowrap; padding-top: 20px; padding-bottom: 20px; .gap-space { display: inline-block; width: 157px; height: 12px; &.last { width: 171px; } } .line-box { box-sizing: border-box; position: relative; display: inline-block; width: 10px; height: 12px; border-right: 1px solid #ccc; &.higher { height: 20px; } .num { position: absolute; bottom: -18px; right: -7px; font-size: 12px; } } } </style> 逻辑实现以上标签布局及样式就简单带过,接下来重点解析js逻辑实现。监听滚动动画停止我们在滑动的时候,由于惯性在手指离开的时候,会继续滚动一段距离才停下,所以我们需要监听滚动停止事件。重点来了,小程序该组件当前只提供了手指离开的事件,并没有提供动画滚动结束的api,所以只能取巧实现,代码如下:function onScroll(e) { if (scrollEndTimer) { clearTimeout(scrollEndTimer) scrollEndTimer = null } scrollEndTimer = setTimeout(() => { // 滑动结束 }, 300) } 通过判断onScroll事件回调,如果300毫秒内没有回调则判断为动画滚动结束(注意:300毫秒参数来源于真机调试测试出来的值,不排除个别机型有差异)滚动到指示器中间位置,进行精确定位在滚动的时候,我们的浮标可能落在两条线中间区域,这时候就要判断下,滑动的位置是否超过一半,如果超过一半则跳去下条线的位置,否则回到上条线的位置。function onScroll(e) { const left = e.detail.scrollLeft const correctLeft = Math.floor(left / boxWidth) * boxWidth const leftMore = left % boxWidth < boxWidth / 2 ? 0 : boxWidth scrollLeft.value = correctLeft + leftMore } 获取间隔盒子实际渲染宽度上面的boxWidth就是线条之间的间隔(盒子宽度),这边是设为20rpx。注意,这里rpx单位也就意味着他会根据不同设备可能会出现不一样的实际渲染宽度(px单位),例如iPhone 14/Pro 上渲染是10px,iPhone 14 Pro Max则渲染出11px,所以我们在进行计算滑动位置时,需要获取到页面上实际渲染的宽度。实现如下:const { ctx } = getCurrentInstance() onMounted(() => { const query = uni.createSelectorQuery().in(ctx) query .select('#box0') .boundingClientRect((data) => { if (data && data.width) { boxWidth = data.width } }) .exec() }) 注意,以上代码是通过封装组件方式实现,所以在获取元素大小位置信息时,需要传入this参数,而上面的ctx是uniapp vue3 setup写法。处理边界值在滑动到最末端时,由于弹性动画效果,会出现超过最大值或低于最小值,需要处理下实时显示的值let setValueTimer = null function onScroll(e) { if (!setValueTimer) { setValueTimer = setTimeout(() => { const left = e.detail.scrollLeft const val = props.min + Math.floor(left / boxWidth) slideNum.value = val > props.max ? props.max : val slideNum.value = val < props.min ? props.min : val setValueTimer = null }, 60) } } 体验优化:添加震动反馈为了更好的体验,我们可以在滑动的时候添加震动反馈,但是这里需要注意的是,iPhone 由于很早之前就使用tapic engine震动带来非常好的统一震动体验,所以在iOS端加上震动能增强体验。但是安卓端由于机型众多,使用的震动反馈强度也不一样,反而容易带来不好的体验,所以建议通过判断系统只在iOS端加上震动反馈。let vibrateShortTimer = null const systemInfo = uni.getSystemInfoSync() const isIOS = systemInfo.platform === 'ios' function onScroll(e) { if (isIOS && !vibrateShortTimer) { vibrateShortTimer = setTimeout(() => { uni.vibrateShort({ type: 'light', }) vibrateShortTimer = null }, 300) } } 最后看下实现效果吧微信小程序搜索【识光】小程序可以查看体验,或者评论区扫码即可查看不完美的地方经过真机测试,在部分安卓手机上会出现浮标线未能对齐间隔线的细微间距,这个可能由于分辨率或其他原因影响暂时无法保证做到所有机型完美对齐。获取组件源码目前该组件已经封装成组件使用(uniapp vue3 setup),需要源码的欢迎私信我获取呀~(^_-)
Vue
0
0
0
浏览量2045
前端码农

uniapp+vue3 setup+ts 开发小程序实战(路由篇)

前言在这一节,我们先回顾下小程序路由基础知识,然后针对小程序路由存在的问题做相关封装优化。小程序路由基础知识API小程序路由一共有5个API:switchTab:跳转到 tabBar 页面,并关闭其他所有非 tabBar 页面reLaunch: 关闭所有页面,打开到应用内的某个页面redirectTo: 关闭当前页面,跳转到应用内的某个页面。但是不允许跳转到 tabbar 页面navigateTo: 保留当前页面,跳转到应用内的某个页面。但是不能跳到 tabbar 页面。navigateBack: 关闭当前页面,返回上一页面或多级页面。页面间传递方式页面间传递参数是通过 url 拼接参数的方式,如传递一个id=1的参数写法如下:uni.navigateTo({ url: '[目标页面地址]?id=1' }) 然后在目标页面的onLoad生命周期钩子获取到上个页面传递的参数。路由优化封装目的针对以上路由基础知识,以下的路由封装方法,主要出于以下5个目的:1、简化写法:通过上面的基础知识我们了解到,页面间跳转需要这么写:uni.navigateTo({ url: '/pages/index/index' }) 这种写法无疑过于繁琐,目标希望简化成:router.navigate('index')2、typescript类型提示既然是typescript项目,我们就需要尽可能给代码完善类型提示:1、在路由跳转时,封装的写法能够提供路由别名候选提示,并在我们写错别名时给予错误提示router.navigate('details') // 写成detalis出现错误提示 2、获取路由参数时,路由参数应该有类型提示3、优化传参:原生通过参数拼接的方式,主要有以下几个弊端问题:在写法上不便:需要我们先将参数转化后再拼接在链接后面,如果遇到较复杂点的json数据还需做进一步处理取值被约束:获取参数只能在onLoad生命周期钩子里面获取。无法跨页面使用参数:如上图。假设页面a传递参数给到b页面使用,而c页面也恰好需要用到a页面传递给b页面的某些参数,那么这时候就需要b页面跳去c页面时再做一次传递,这样不仅写法麻烦,也给后续的维护带来一定的麻烦。reLaunch和switchTab方法不支持传递参数:不知道官方设计的用意如何,不支持传参不代表实际场景不需要用到。这里举个真实场景例子,证明switchTab也是有需要支持传递参数的时候:如上图:假设我们的小程序在一进入时需要进行一个初始化设置,用户在完成某些选择之后(如选择性别、年龄等基础信息),才进入到小程序tab页面,这时候点击“点击进入”调用的是switchTab方法,我们需要将用户初始设置的参数传递到主页做进一步处理。当然,解决的方法有多种,例如我们可以调用本地存储的方法暂存,然后再取值,但是通过路由传参统一管理参数无疑是更适合的写法综合以上弊端问题的分析,这里的解决的方案也很简单:抛弃原生路由拼接参数传递方式,直接定义一个全局对象存储。4、navigateTo方法处理用户多次点击小程序调用navigateTo方法,实际上会创建一个新的webview,接着初始化新页面的生命周期里面的代码逻辑,如果遇到页面比较复杂的情况,初始化就可能比较耗时,导致用户点击跳转下一页会可能就会出现卡顿。此时用户可能多次点击触发navigateTo方法,进而有可能导致重复加载同一个页面。5、路由返回方法增加携带参数在某些场景中,我们希望页面返回时,能够携带参数给上一级页面。例如用户在A页面点击选择地址,然后跳转去一个地址列表页面,在地址列表页面用户完成选择某项地址后,携带该地址返回,A页面接收到后再更新地址。目标希望可以这么调用:router .navigate('page', { id: 1 }) .then((data) => { // 接收到上个页面返回的数据 console.log(data) }) .catch((err) => { console.log(err) }) 上手封装有了以上封装设计目的,接下来就开始正式上手封装代码。我们在src目录下新建个router文件夹,里面新建index.ts、pages.ts、types.ts三个文件。在上面的路由优化设计目标中,我们第一个希望优化的目标是希望简化路由写法成:router.navigate('index')这就需要我们存放一个路由页面集合,给每个页面路径设置别名,pages.ts内容增加:// 主包 const mainPackage = { index: '/pages/index/index', } // 分包 const subPackage = { subIndex: '/package-sub/pages/index/index', } const pages = { ...mainPackage, ...subPackage, } export default pages 在index.ts中引入,并定义页面别名类型:import pages from './pages' type PageNames = keyof typeof pages 接下来处理路由传参,先定义一个store存放所有页面路由参数:const routeStore: Record<PageNames, unknown> = {} 假设我们从首页点击进入课程详情页,路由需要传递一个id代表课程id,我们可以在types.ts新增:export interface CourseDetails { id: number } 然后在index.ts中引入:import { CourseDetails } from './types' 接着定义ObjectType泛型:type ObjectType<T> = T extends 'courseDetails' ? CourseDetails : never 路由方法:function navigate<T extends PageNames>(page: T, params: ObjectType<T>) { routeStore[page] = params uni.navigateTo({ url: pages[page] }) } 定义获取路由参数的方法:这里需要说明的是获取的路由参数应该是只读的,可以用vue的readonly方法包裹后返回:import { readonly, DeepReadonly } from 'vue' export function getRouteParams<T extends PageNames>( page: T, ): DeepReadonly<ObjectType<T>> { const p = routeStore[page] as ObjectType<T> return readonly(p) } 处理navigateTo方法处理用户多次点击let navigateLock = false function navigate<T extends PageNames>(page: T, params?: ObjectType<T>) { if (navigateLock) return navigateLock = true routeStore[page] = params uni.navigateTo({ url: pages[page], complete() { navigateLock = false }, }) } 路由返回增加参数要实现这个功能,我们可以借助uniapp跨页面通信API:uni.emit及uni.emit 及uni.emit及uni.once。(如果有不了解该的同学可以移步uniapp文档了解:点击这里)然后在调用navigateTo进行跳转时,将事件名传递给下个页面,下个页面在调用navigateBack返回时,通过传递过来的事件名调用uni.$emit触发事件。代码逻辑如下:let navigateLock = false function navigate<T extends PageNames>( page: T, params?: ObjectType<T>, ): Promise<any> { if (navigateLock) return const eventName = Math.floor(Math.random() * 1000) + new Date().getTime() + '' // 生成唯一事件名 navigateLock = true routeStore[page] = params uni.navigateTo({ url: `${pages[page]}?eventName=${eventName}`, // 这里将触发事件名传递给下个页面 complete() { navigateLock = false }, }) return new Promise<any>( (resolve, reject) => ( uni.$once(eventName, resolve), uni.$once(eventName, reject) ), ) } interface BackParams { /** 返回页面层级 */ delta: number /** 返回携带的数据 */ data: any } function back({ delta, data }: BackParams = { delta: 1, data: null }) { // 获取当前路由信息 const currentRoute = getCurrentPages().pop() // 拿到路由事件名参数 const eventName = currentRoute.options.eventName uni.$emit(eventName, data) uni.navigateBack({ delta, }) } 完整内容如下:import { CourseDetails } from './types' import pages from './pages' type PageNames = keyof typeof pages type ObjectType<T> = T extends 'courseDetails' ? CourseDetails : never const routeStore = {} as Record<PageNames, unknown> export function getRouteParams<T extends PageNames>( page: T, ): DeepReadonly<ObjectType<T>> { const p = routeStore[page] as ObjectType<T> return readonly(p) } let navigateLock = false function navigate<T extends PageNames>( page: T, params?: ObjectType<T>, ): Promise<any> { if (navigateLock) return const eventName = Math.floor(Math.random() * 1000) + new Date().getTime() // 生成唯一事件名 navigateLock = true routeStore[page] = params uni.navigateTo({ url: `${pages[page]}?eventName=${eventName}`, complete() { navigateLock = false }, }) return new Promise<any>( (resolve, reject) => ( uni.$once(eventName, resolve), uni.$once(eventName, reject) ), ) } function redirect<T extends PageNames>(page: T, params?: ObjectType<T>) { routeStore[page] = params uni.redirectTo({ url: pages[page] }) } function reLaunch<T extends PageNames>(page: T, params?: ObjectType<T>) { routeStore[page] = params uni.reLaunch({ url: pages[page] }) } function switchTab<T extends PageNames>(page: T, params?: ObjectType<T>) { routeStore[page] = params uni.switchTab({ url: pages[page] }) } interface BackParams { /** 返回页面层级 */ delta?: number /** 返回携带的数据 */ data?: any } function back({ delta, data }: BackParams = { delta: 1, data: null }) { const currentRoute = getCurrentPages().pop() const eventName = currentRoute.options.eventName uni.$emit(eventName, data) uni.navigateBack({ delta, }) } const router = { navigate, redirect, reLaunch, switchTab, back, } export default router 使用示例:路由跳转下一页获取路由参数返回携带参数function next() { router .navigate('courseDetails', { id: 1 }) .then((data) => { console.log('上个页面返回的数据') console.log(data) }) .catch((err) => { console.log(err) }) } function pageBack() { router.back({ data: { msg: '这是返回携带的数据', }, }) } 原生路由传参简化取参以上路由传参是假设在用户从首页打开小程序的情况下,但是有些场景下,如分享进入,消息通知点击进入,这时候携带的参数只能在url中。对此,uniapp也针对获取路由参数方式做了简化,可以通过定义 props 来直接接收 url 传入的参数,而不必在onload生命周期钩子中获取。<script setup> // 页面可以通过定义 props 来直接接收 url 传入的参数 // 如:uni.navigateTo({ url: '/pages/index/index?id=10' }) const props = defineProps({ id: String, }); console.log("id=" + props.id); // id=10 </script> 这时,对于一个多入口打开的页面,要采取哪种方式获取路由参数,可以根据场景值判断,详见文档场景值总结至此,我们已经完成了路由的优化封装目标。现在总结下使用步骤:pages.json中新增页面路径router -> pages.ts 新增页面别名配置如需传参,在types中增加参数类型接口router -> index.ts中修改ObjectType,如新增一个Order,ObjectType修改如下:type ObjectType<T> = T extends 'courseDetails' ? CourseDetails : T extends 'order' ? Order : never 作者:码克吐温 链接:https://juejin.cn/post/7120160996475289607 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
Vue
0
0
0
浏览量2039
前端码农

uniapp+vue3 setup+ts 开发小程序实战(起始篇)

前言如今,小程序已经成了日常生活不可缺少的应用之一,掌握小程序开发对于前端来说,几乎是一个必备的技能。由于原生小程序语法开发体验差,缺乏生态插件等原因,已经诞生过许多第三方框架,如uniapp、taro、wepy、mpvue等,而随着时间的推移,uniapp及taro框架就如同现在vue及react两个主流前端框架一样,被大多数小程序开发者采用。对于使用vue技术栈的同学来说,想必都知道vue3已经如火如荼,vue主流生态都在“争先恐后”升级至vue3版本,而作为vue语法开发小程序的uniapp框架,也早已经跟上这波潮流,推出了vue3 + vite + setup + typescript开发小程序版本,不论在开发体验还是性能上,都带来了质的飞跃。(详见官方社区文章《vue3和vite双向加持,uni-app性能再次提升》)“新事物的诞生往往意味着新的开始”,在vue3 + vite + setup + typescript的未来趋势下,意味着我们需要对以前vue2版本学习的经验知识也要跟着做一番迭代,甚至推翻重来。接下来将从初始化代码项目开始,一步步带你基于uniapp + vue3 + vite + setup + typescript去搭建初始化一个小程序项目,在这过程中会引导你思考如何去做封装优化相关API方法等。举个例子,路由跳转微信官方API写法如下:wx.navigateTo({ url: '/pages/index/test?id=1', }) 这种写法平时写个demo倒还好,但在真实项目中涉及众多页面跳转,每次跳转都要写这么长的一段代码,这种体验是很差的。在路由封装模块章节中,经过封装的后的写法如下:router.navigate('index', {id: 1}) 这么一对比是不是一下子清爽很多了呢?你需要准备的前置知识请确保你已经学习过微信小程序基础知识,常见api和uniapp基础知识了解vue3基础知识,composition setup写法掌握typescript基础知识项目初始化全局安装vue-clinpm install -g @vue/cli@4 初始化代码npx degit dcloudio/uni-preset-vue#vite-ts my-vue3-project (如命令行创建失败,请直接访问 gitee下载模板)安装vscode插件使用script setup语法,需要配合volar插件使用,在vscode插件中搜索volar:然后选择Vue Volar extension Pack插件(该插件包含了vue3项目常用到的各种插件)。至此,我们的准备工作就算完成了!代码基建设置初始化项目的代码,还非常的简陋,此时我们不能急着立刻上手写业务代码,而是要先完成“基建”工作,只有基建搭好了,才方便后续项目代码的开发和维护。这些基建工作包括诸如:设置统一代码风格规范,路径别名,样式管理等,接下来就开始一步步实现。设置统一代码风格正所谓“无规矩不成方圆”,一个好的项目代码,必定是有着一定的代码规范约束。目前主流方案是使用Eslint + Prettier进行设置。安装Eslint 依赖在终端中输入:npm i @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint eslint-config-prettier eslint-plugin-import eslint-plugin-prettier eslint-plugin-vue vue-eslint-parser -D 安装完依赖后,我们在根目录下新建.eslintrc.js文件,内容如下:module.exports = { root: true, env: { browser: true, node: true, es6: true, }, parser: 'vue-eslint-parser', parserOptions: { parser: '@typescript-eslint/parser', ecmaVersion: 2020, sourceType: 'module', jsxPragma: 'React', ecmaFeatures: { jsx: true, tsx: true, }, }, plugins: ['@typescript-eslint', 'prettier', 'import'], extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:vue/vue3-recommended', 'prettier', ], overrides: [ { files: ['*.ts', '*.tsx', '*.vue'], rules: { 'no-undef': 'off', }, }, ], rules: { 'no-restricted-syntax': ['error', 'LabeledStatement', 'WithStatement'], camelcase: ['error', { properties: 'never' }], 'no-var': 'error', 'no-empty': ['error', { allowEmptyCatch: true }], 'no-void': 'error', 'prefer-const': [ 'warn', { destructuring: 'all', ignoreReadBeforeAssign: true }, ], 'prefer-template': 'error', 'object-shorthand': [ 'error', 'always', { ignoreConstructors: false, avoidQuotes: true }, ], 'block-scoped-var': 'error', 'no-constant-condition': ['error', { checkLoops: false }], 'no-redeclare': 'off', '@typescript-eslint/no-redeclare': 'error', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-non-null-asserted-optional-chain': 'off', '@typescript-eslint/no-var-requires': 'off', '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', }, ], 'no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', }, ], // vue 'vue/no-v-html': 'off', 'vue/require-default-prop': 'off', 'vue/require-explicit-emits': 'off', 'vue/multi-word-component-names': 'off', // prettier 'prettier/prettier': 'error', // import 'import/first': 'error', 'import/no-duplicates': 'error', 'import/order': [ 'error', { groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type', ], pathGroupsExcludedImportTypes: ['type'], }, ], }, } 新建.eslintignore文件创建ESLint忽略文件配置 .eslintignore,来指定我们不需要进行检查的目录或文件node_modules dist *.md *.woff *.ttf .vscode .idea 新建.prettierrc文件{ "semi": false, "tabWidth": 2, "trailingComma": "all", "singleQuote": true, "endOfLine": "auto" } 新建.prettierignore文件**/*.svg **/*.ico package.json package-lock.json /dist .DS_Store .eslintignore *.png .editorconfig .gitignore .prettierignore .eslintcache *.lock yarn-error.log **/node_modules/** vscode安装eslint跟prettier插件路径别名设置修改vite.config.ts,这里我们先设置两个别名,一个是针对src下代码文件,一个是针对图片静态文件,内容如下:import path from 'path' import { defineConfig } from 'vite' import uni from '@dcloudio/vite-plugin-uni' // https://vitejs.dev/config/ export default defineConfig({ plugins: [uni()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), '@img': path.resolve(__dirname, 'src/static/images'), }, }, }) 接着我们在.vue文件的template中可以这么写:<image class="logo" src="@img/logo.jpg" /> 假设我们要引入src -> router -> index.ts文件,在script里面这么写:可以看到,此时ts会报找不到模块的错误提示,此时我们需要在tsconfig.json文件做相关修改:在compilerOptions下添加 "paths": { "@/*": ["src/*"] } 即可。样式管理css预处理比较成熟的有sass,less,stylus,大家可以根据自己选择对应的css预处理器。这里以sass为例:先安装相关依赖npm i sass sass-loader -D 接着在src目录下创建styles文件夹,存放样式相关文件。新建vars.scss文件:管理颜色变量例如:$font-size: 28rpx; $primary-color: #54d339; 新建mixins.scss文件(以下示例供参考)例如@mixin flex-row { display: flex; align-items: center; } @mixin flex-column { display: flex; flex-direction: column; } // 文字超出隐藏 @mixin text-eli { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-box-orient: vertical; } 新建common.scss:全局公共样式(以下示例供参考)@import "./vars.scss"; @import "./mixins.scss"; page { box-sizing: border-box; font-size: $font-size; } view, text { font-size: $font-size; box-sizing: border-box; color: #333; } // 去除按钮默认边框 button::after { border: none; } .flex-row { @include flex-row(); } .flex-row-between { @include flex-row(); justify-content: space-between; } .flex-row-around { @include flex-row(); justify-content: space-around; } .flex-row-center { @include flex-row(); justify-content: center; } .flex-row-end { @include flex-row(); justify-content: flex-end; } .flex-column { @include flex-column(); } .flex-column-center { @include flex-column(); align-items: center; justify-content: center; } .flex1 { flex: 1; height: 100%; } .text-line1 { @include text-eli(); -webkit-line-clamp: 1; } .text-line2 { @include text-eli(); -webkit-line-clamp: 2; } /* 间隔相关 */ .pad20 { padding: 20rpx; } .mb32 { margin-bottom: 32rpx; } .mb40 { margin-bottom: 40rpx; } .mt60 { margin-top: 60rpx; } .ml20 { margin-left: 20rpx; } .ml40 { margin-left: 40rpx; } /* 字体大小相关 */ .font24 { font-size: 24rpx; } .font48 { font-size: 48rpx; } .font36 { font-size: 36rpx; } .font32 { font-size: 32rpx; } .font-bold { font-weight: bold; } .text-center { text-align: center; } .text-white { color: #fff; } .text-color-main { color: $main; } .text-color-6 { color: #666; } .text-color-9 { color: #999; } .bg-white { background-color: #fff; } .bg-gray { background-color: $bg-gray; } App.vue文件中引入<style lang="scss"> /*全局公共样式 */ @import "./styles/common.scss"; </style> 配置自动导入颜色变量我们在vars.scss文件中定义的颜色变量,在页面中使用时,需要手动导入才能使用。那要怎么实现自动导入呢?我们可以在vite.config.js中配置即可:在return对象下新增:css: { preprocessorOptions: { scss: { additionalData: `@import "@/styles/vars.scss";`, }, }, } 这样我们在页面中就可以直接使用vars中定义的颜色变量了。此时还没完,我们还可以借助一个插件帮助我们识别定义的变量: SCSS IntelliSense在vscode中安装该插件后,如下图可以看到已经给出提示,开发体验又上升了一个台阶!自动导入vue方法vue3 script setup 写法中,组件间通信有defineProps跟defineEmits这种编译器宏方法,无需导入就可以直接使用。而对于vue当中导出的代码,我们还是需要手动显示引入,如下:import { computed, ref } from 'vue' const count = ref(0) 那有没有办法像defineProps等编译器宏方法一样,无需手动导入就可以直接使用呢?对此,我们可以使用unplugin-auto-import npm包实现。安装依赖包npm i -D unplugin-auto-import vite.config.ts中引入import AutoImport from 'unplugin-auto-import/vite' plugins: [ uni(), AutoImport({ imports: ['vue', 'uni-app'], dts: './auto-imports.d.ts', // 安装好依赖后,重新运行编译即可自动在根目录下生成此声明文件 }), ] tsconfig.ts中引入声明文件接着我们需要在tsconfig.ts文件include属性中引入声明文件,否则直接使用ts会报错。"include": [ "src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "auto-imports.d.ts" ] 接着我们就可以直接在代码中无需导入直接使用vue中方法了:// import { computed, ref } from 'vue' 这行代码不用写了 const count = ref(0) 总结至此,我们已经完成了项目代码初始化及基建工作(代码风格统一,样式管理,路由别名设置),并通过一些插件提升代码开发体验。当然这只是起始篇,接下来更有趣的请移步《状态管理篇》。
Vue
0
0
0
浏览量2036
前端码农

uniapp+vue3 setup+ts 开发小程序实战(用户指引蒙层实现)

对于一些复杂的页面,往往可以添加功能引导蒙层帮助用户快速上手功能。如下图:原理显然,我们需要获取到操作按钮在页面当中的位置坐标及大小,然后在对应位置上方设置一个透明可视区域,这个可以利用css box-shadow属性来创建阴影蒙层。接着,对于有多个步骤引导的,在切换下一个时为了不那么生硬,需要添加动画过渡效果,这里可以借助微信小程序的animate API实现。封装组件一个功能页当中的代码,应该尽可能只保留该页面功能业务相关逻辑代码,对于用户引导这种与业务无关的代码应该抽离到外部组件当中,下面就来带大家如何封装实现。可以看到,一个提示引导有两部分组成:操作区域+操作提示。通常设计,操作提示往往是一张图片素材,但是这里为了简化代码,改为纯文字描述,在理解了原理之后大家可以自己在组件增加属性配置即可。在src->components目录下,新建user-guide.vue组件,组件代码如下:<template> <view v-if="show" class="user-guide"> <view id="visual-view" class="visual-view"> <view class="tip">{{ currentTip.tip }}</view> </view> <view class="btn-list"> <button class="btn" @click="close">知道了</button> <button v-if="!isEnd" class="btn next" @click="moveView">下一步</button> </view> </view> </template> <script setup lang="ts"> import { ref, getCurrentInstance, onUpdated } from 'vue' interface IProps { /** 是否显示 */ show: boolean /** 提示信息列表 */ list: TipItem[] } interface TipItem { /** 操作区域位置坐标及大小 */ width: number height: number top: number left: number /** 操作提示内容 */ tip: string } const props = defineProps<IProps>() const emit = defineEmits(['update:show']) const step = ref(0) const isEnd = computed(() => step.value === props.list.length - 1) let isFirstUpdate = false onUpdated(() => { if (!isFirstUpdate) { // 初始化第一个提示 currentTip.value = props.list[0] isFirstUpdate = true } }) // 关闭提示 function close() { emit('update:show', false) } // @ts-ignore const { ctx } = getCurrentInstance() // 给个初始值,否则template中渲染对象报错 const currentTip = ref<TipItem>({ width: 0, height: 0, top: 0, left: 0, tip: '', }) // 切换下一个 function moveView() { const preTip = currentTip.value step.value += 1 currentTip.value = props.list[step.value] const nextTip = currentTip.value ctx.$scope.animate( '#visual-view', [ { top: `${preTip.top}px`, left: `${preTip.left}px`, width: `${preTip.width}px`, height: `${preTip.height}px`, }, { top: `${nextTip.top}px`, left: `${nextTip.left}px`, width: `${nextTip.width}px`, height: `${nextTip.height}px`, }, ], 300, // 动画过渡时间 () => { // 调用 animate API 后会在节点上新增一些样式属性覆盖掉原有的对应样式,在动画结束后需要清除增加的属性 ctx.$scope.clearAnimation() }, ) } </script> <style lang="scss"> .user-guide { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 9; .visual-view { position: absolute; width: 200rpx; height: 100rpx; background: transparent; border-radius: 10px; box-shadow: 0 0 0 1999px rgba(0, 0, 0, 0.55); z-index: 10; .tip { position: absolute; z-index: 11; bottom: -30px; color: #fff; } } .guide-btn { position: absolute; z-index: 11; bottom: 200px; } } .btn-list { position: fixed; bottom: 100px; display: flex; width: 100%; justify-content: center; z-index: 11; .btn { margin: 0; &.next { color: #5080ff; margin-left: 10px; } } } </style> 需要说明的是,vue3 compositon写法中是没有this写法的,如果我们要获取挂载在this上面的API,可以通过import { getCurrentInstance } from 'vue' const { ctx } = getCurrentInstance() 拿到。而uniapp 小程序端this属性或方法是挂载到ctx下的$scope属性上,所以moveView方法中才有了如下写法: ctx.$scope.animate() 页面中使用组件<template> <view id="tip-one" >点击区域一</view> <view id="tip-two" >点击区域二</view> <view id="tip-three" >点击区域三</view> <UserGuide v-model:show="showGuide" :list="guideList" /> </template> <script setup lang="ts"> import { ref } from 'vue' import UserGuide from '@/components/user-guide.vue' interface TipItem { /** 操作区域位置坐标及大小 */ width: number height: number top: number left: number /** 操作提示内容 */ tip: string } const showGuide = ref(true) const guideList = ref<TipItem[]>([]) // 假设我们页面中需要提示三个操作区域,提示内容如下: const guideTips = ['点击这里赚积分', '点击这里得礼品', '点击分享赚...'] // 获取点击区域在页面中的坐标信息 const query = uni.createSelectorQuery() query.select('#tip-one').boundingClientRect() query.select('#tip-two').boundingClientRect() query.select('#tip-three').boundingClientRect() query.exec((res) => { guideList.value = res.map((item: TipItem, index: number) => { item.tip = guideTips[index] return item }) }) </script> 值得一提的是,在vue3中,已经移除了.sync修饰符,改为v-model:propName写法,如上面代码中v-model:show运行效果:总结本文实现了用户指引蒙层组件的封装,介绍了uniapp中如何使用微信小程序挂载在this下的动画方法api及如何获取页面元素坐标位置信息大小,大家理解原理后可以根据自己的需求改造增加组件配置属性即可。
Vue
0
0
0
浏览量2026
前端码农

uniapp+vue3 setup+ts 开发小程序实战(状态管理篇) 2022-0

Pinia是什么Pinia是vue团队推荐的下一代状态管理方案,相比之前的vuex方案,Pinia具有以下特点:配合vue3 Componsition API写法,更可靠的TypeScript 类型推断支持Pinia 没有 Mutations,可以直接修改state数据,Actions 支持同步和异步提供扁平结构,没有模块的嵌套结构等安装依赖npm i pinia -S 接着src目录下创建stores文件夹(注意:这里用复数形式命名以强调pinia的多状态实例的特性,也就是上面提到的特点3)初始化工作创建index.ts文件:stores文件夹下新建index.ts:import { createPinia } from 'pinia' const pinia = createPinia() export default pinia 在main.ts中引用:import { createSSRApp } from 'vue' import App from './App.vue' import pinia from './store' export function createApp() { const app = createSSRApp(App) app.use(pinia) return { app, } } 以上就完成了初始化工作,下面我们定义一个user模块说明如何使用定义user模块在stores目录下新建user文件夹,在其目录下我们新建两个文件:index.ts和types.ts(管理数据结构)index.ts:import { defineStore } from 'pinia' import { RootState } from './types' export const useUserStore = defineStore('user', { state: (): RootState => ({ userInfo: {}, token: '', }), getters: { // 示例返回大写字符 capName(state) { return state.userInfo.name.toUpperCase() }, }, actions: { async setUserInfo() { // 这里可以发起请求 const userInfo = await getUserInfo() this.userInfo = userInfo }, }, }) defineStore方法第一个参数“user”是模块的名称,值必须是唯一的(多个模块不能重名)state:箭头函数,返回一个对象数据getters:可以理解为计算属性,对state中的数据做进一步计算处理actions:封装业务逻辑,同步/异步修改state数据值得一提的是,对于store模块命名写法,有个约定俗成的写法:使用“use”+ 功能模块名称,如上面的useUserStore页面中使用<template> <view>{userStore.userInfo.name}</view> </template> <script setup lang="ts"> import { useUserStore } from '@/stores/user' const userStore = useUserStore() console.log(userStore.userInfo) </script> 解构state如果要使用解构写法获取值,而又不丢失响应式,我们需要用到storeToRefs方法const { userInfo } = storeToRefs(userStore) userInfo.value.name = 'username' 修改state直接修改:userStore.userInfo = {}$patch批量修改(性能更好):// $patch有两种写法 // 传入对象:适合同时修改多个不复杂的数据 store.$patch({ userInfo: {}, token: '', }) // 传入函数写法:上面传入对象写法在遇到复杂数据时,成本很高 //(例如,从数组中推送、删除、拼接元素)都需要创建一个新集合,这时就可以传入一个函数 cartStore.$patch((state) => { state.items.push({ name: 'shoes', quantity: 1 }) state.hasChanged = true }) $reset恢复初始值:例如我们在state中定义一个userInfo初始值userInfo: { name: '默认用户名', avatar: '默认用户头像' } 假设用户登录后我们修改了上面的用户信息,退出登录时,我们可以直接调用userStore.$reset()恢复初始值。不过要注意的是,它恢复的是整个state值,并不能只恢复state下面的单个值。对此,我们可以将有恢复初始值的需求的变量,用一个变量存储然后在需要恢复时,可以在action中定义一个方法重新赋值即可。监听状态如果你需要监听状态做一些处理,例如将数据持久化到本地,可以使用$subscribe()方法。userStore.$subscribe((mutations, state) => { uni.setStorageSync('userInfo', state.userInfo) }) 数据持久化插件:pinia-plugin-persistedstate在只需要本地存储一小部分字段时,可以通过上面监听状态钩子简易实现。但如果需要存储大量数据字段,则可以使用与之搭配的插件:pinia-plugin-persistedstate安装npm i pinia-plugin-persistedstate 修改stores/index.tsimport { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) export default pinia user模块export const useUserStore = defineStore('user', { state: (): RootState => ({ userInfo: { id: null, name: '', }, token: '', userName: '', }), persist: { key: 'store-key', // 本地存储key storage: { setItem: uni.setStorageSync, getItem: uni.getStorageSync, }, }, actions: { setName(name: string) { this.userName = name }, }, }) 打开微信开发者工具可以看到:可以看到上面存储的是整个user模块的state数据,如果只需要存储state下的某些字段,可以这么写: persist: { key: 'store-key', paths: ['userInfo.name'], storage: { setItem: uni.setStorageSync, getItem: uni.getStorageSync, }, } 微信开发工具中显示如下:总结至此,已经完成了pinia状态管理使用及配合pinia-plugin-persistedstate插件实现数据本地存储,接下来请移步《网络请求封装篇》。
Vue
0
0
0
浏览量2027
前端码农

uniapp+vue3 setup+ts 开发小程序实战(本地存储封装)

为何要封装本地存储API1、增加typescript数据类型提示:假设我们要获取本地数据,写法如下: const data = uni.getStorageSync(key) 这时我们输入key及访问data时,是获取不到类型提示的。2、增加响应式:假设我们获取到上面的data之后,对data做了一些修改,这时我们又需要手动再次调用更新到本地data = newValue uni.setStorageSync(key, data) 目标希望封装之后的方法,在修改data后,自动调用存储api更新到本地(借助vue watch钩子)。封装根据上面的目标,先直接看封装后的代码:import { ref, Ref, watch } from 'vue' interface Company { companyName: string companyId: number } type StorageKeys = 'company' | 'companyList' type ObjectType<T> = T extends 'company' ? Company : T extends 'companyList' ? Company[] : never export function setStorage<T extends StorageKeys>( key: T, data: ObjectType<T>, ): void { uni.setStorageSync(key, data) } export function getStorage<T extends StorageKeys>( key: T, initValue: any = '', ): ObjectType<T> { const data = uni.getStorageSync(key) || initValue return data as ObjectType<T> } export function getStorageRef<T extends StorageKeys>( key: T, initValue: any = '', ): Ref<ObjectType<T>> { const data = uni.getStorageSync(key) || initValue const result = ref(data) watch(result.value, () => { setStorage(key, result.value) }) return result as Ref<ObjectType<T>> } company | companyList: 示例数据,当前公司及公司列表StorageKeys:定义所有本地存储数据keysObjectType :定义key对应的数据结构setStorage:存储方法getStorage:根据key获取数据方法(非响应式),适合于非直接渲染到template的场景,例如在函数中进行运算getStorageRef:根据key获取数据方法(响应式),适合于直接渲染到template的场景,同时在修改数据时,无需手动调用存储api更新到本地使用<script setup lang="ts"> import { setStorage, getStorageRef } from "@/utils/storage" const companyList = getStorageRef("companyList", []) </script> 使用案例:搜索历史使用本地存储最常见的案例就是存储搜索历史,下面代码就是当用户点击搜索结果后的代码逻辑function next(item: Company) { const companyList = getStorageRef("companyList", []) const idx = companyList.value.findIndex(i => i.companyId === item.companyId) if (idx < 0) { companyList.value.unshift(item) } else { // 先删掉已有的,然后追加到前面(注意:这里不用手动调用存储方法更新到本地啦) companyList.value.splice(idx, 1) companyList.value.unshift(item) } } 总结以上封装只是用到了setStorageSync及setStorageSync两个方法,这两个已经能够满足我们绝大部分使用场景,如果你对本地存储api还有其他需求,如使用异步,开启加密存储(使用setStorage方法),可以自行添加封装方法即可。
Vue
0
0
0
浏览量2013
前端码农

uniapp+vue3 setup+ts 开发小程序实战(UI 组件篇)

为了方便快速进行业务功能开发,通常会使用到第三方UI组件库。经过调研,发现以下三个UI组件库较为成熟,并可以在uniapp vue3框架下微信小程序中正常使用,它们之间的优缺点见如下:| | 优点 | 缺点 | 文档地址 | | --- | --- | --- || --- | |uni-ui| uniapp官方拓展组件库,组件数量丰富,高性能,跨平台;支持easycom自动引入使用到的组件 | 部分组件UI美观度欠佳 | 点击打开| |weui| 微信小程序官方拓展组件库,视觉体验与微信保持一致; 支持dark mode| 组件数量较少,更新迭代慢 | 点击打开| |vant-ui| 组件数量较为丰富,并提供一些开箱即用的复杂组件,如分享面板、侧边导航组件;支持主题定制,文档友好;社区活跃更新维护迭代快 | / | 点击打开|这里指的一提的是,uniapp下还有个优秀的跨平台UI组件库:uview。但可惜的是目前并不兼容uniapp vue3,暂时无法使用。至于实际项目需要使用哪个UI框架,大家可以自己需求选择判断。接下来分别介绍这三个UI组件库的引入使用方法:引入uni-ui安装npm i @dcloudio/uni-ui 配置easycom接着打开项目src目录下的 pages.json 并添加 easycom 节点:何为easycome?简单理解就是按需引入组件,打包后会自动剔除没有使用的组件,详情点击这里{ "easycom": { "autoscan": true, "custom": { "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue" } }, "pages": [] } 在 template 中使用组件<uni-badge text="1"></uni-badge> 引入weui由于是微信“亲儿子”,weui有特有的通过 useExtendedLib 扩展库 的方式引入,这种方式引入的好处是不会计入代码包大小。但是经过测试,uniapp 不支持该方式引入,若通过该方式引入组件,虽然页面上可能显示正常,但是会在编译时丢失传入组件的属性,也就是无法修改组件属性。经查,uniapp微信小程序组件必须放在src -> wxcomponents目录下。也就是说,我们需要将weui组件手动放入到wxcomponents目录下。步骤如下:首先我们需要获取到weui组件,然而github仓库并没有打包好供直接引入使用的组件,这时我们可以在微信开发者工具新建一个项目,接着在该目录下先npm init -y 接着npm i weui-miniprogram -S 安装好之后,我们点击上面的工具->构建npm接着就能看到新增了miniprogram_npm目录,我们将该目录下的weui-miniprogram拷贝到我们的uniapp项目目录下,也就是上面说的src -> wxcomponents, 并重新命名为weui接着我们在使用到的页面中需要手动引入组件,例如引入搜索框组件,修改pages.json: "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "uni-app", "usingComponents": { "mp-searchbar": "/wxcomponents/weui/searchbar/searchbar" } } }, ] 在 template 中使用组件<template> <mp-searchbar :value="search" @input="searchInput" ></mp-searchbar> </template> <script setup lang="ts"> import { ref } from 'vue' const search = ref('') function searchInput(e) { search.value = e.detail.value } <style lang="scss"> 运行效果:引入vant-ui同样的方式,我们需要获取到组件代码,点击vant-ui github仓库地址,将dist目录拷贝到wxcomponents文件夹下,并重新命名为vant。pages.json中引入接着我们在pages.json中手动引入使用到的组件,如引入button: "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "uni-app", "usingComponents": { "van-button": "/wxcomponents/vant/button/index", } } }, ] 开启js编译ES5要注意的,这里我们需要到本地设置中开启js编译ES5,否则会报错。在 template 中使用组件<van-button type="default" @click="">默认按钮</van-button> 注意事项这种组件源码引入的方式,uniapp在编译后的dist目录下生成的文件,并不会像uni-ui通过easycome方式引入自动剔除没有使用到的组件。不过不要紧,微信开发者工具给我们提供了过滤没有使用到文件的功能。点击右上角的详情,切换本地设置选项,然后把“上传时过滤无依赖文件”勾选上:接着我们点击预览,可以看到提示自动过滤了无依赖文件引入tailwind.css关于tailwind.csstailwind.css在爆火之初,不乏许多批评反对的声音,然而现在从star数来看,相信这种纯类名编写样式的写法已经被许多人接受&项目引入使用。其中我觉得很重要的一个原因正如官网的对其的描述:通常我们在编写页面样式时,分两个步骤:编写类名 + 跳转到编写样式区其中编写类名尤其让人头疼,要想写好类名每次还要花一定时间思考类名到底应该叫什么,不同英语水平的人最终导致编写的类名“百花齐放”。跳转到编写样式区就同样让开发体验不佳,尤其是在.vue单文件文件中,样式往往都是写在最底部,修改样式需要反复横跳。(虽然.vue文件在vscode可以一键拆分三个区域,但是依旧有痛点:一是视线依旧需要离开html,二是在笔记本小屏幕中开发这种拆分显示区域过小,通常不会拆分窗口)vue3 compistion组合式api的写法,带来的很大开发体验优势就是不用像之前那样写在固定地方,导致代码量大的情况下查找需要反复横跳,这么一看,是不是感觉它们之间有异曲同工之妙呢?
Vue
0
0
0
浏览量2069
前端码农

uniapp+vue3 setup+ts 开发小程序实战(网络请求封装篇)

前言对于请求的封装,主要有以下目的:添加typescript类型提示在拦截器里面进行一些统一配置,如设置header、针对错误码统一提示等多入口场景下,在未登录时,在拦截器里完成无痕登录后再请求。(如从分享页面进入)其中第三点尤其重要,原因在于:小程序存在多个场景打开入口,如消息通知,公众号菜单栏,分享进入等,打开的页面也不尽相同,由于小程序特有的登录逻辑,登录需要调用wx.login API,很显然,在多入口场景下,我们不能在简单粗暴的每个打开的页面都去写一遍调用wx.login,再请求其他接口。在拦截器中完成无痕登录,便于我们调试接口:我们在调试一些层级较深的页面接口时,可以在微信开发者工具中选择编译指定的页面,这样我们就无需点击多次进入到需要调试接口的页面。但是如果没有在请求拦截中完成预先登录,我们往往也是无法直接请求调试该页面的接口。综上目的,这里选择使用PreQuest这个强大的请求库。PreQuest 是一套 JS 运行时的 HTTP 解决方案,它包含了一些针对不同 JS 运行平台的封装的请求库,并为这些请求库提供了一致的中间件、拦截器、全局配置等功能的体验,还针对诸如 Token 的添加,失效处理,无感知更新、接口缓存、错误重试等常见业务场景,提供了解决方案。可以点击官方文档先做了解安装该请求库针对不同的平台提供了不同的安装包,这里安装两个依赖包:@prequest/miniprogram: 小程序端的请求库@prequest/lock: 请求锁,token 处理的解决的方案npm i @prequest/miniprogram @prequest/lock -S 封装文件在src->utils目录下新建requst.ts文件import { PreQuest, create } from '@prequest/miniprogram' import Lock from '@prequest/lock' import { MiddlewareCallback } from '@prequest/types' import { useUserStore } from '@/stores/user' const userStore = useUserStore() // 这里将token放在pinia user模块中 declare module '@prequest/types' { interface PQRequest { skipTokenCheck?: boolean } } // 全局配置 PreQuest.defaults.baseURL = '请求域名' // 设置header PreQuest.defaults.header = {} const prequest = create(uni.request) // 无痕刷新中间件 const lock = new Lock({ getValue() { return Promise.resolve(userStore.token) }, setValue(token) { userStore.token = token }, clearValue() { userStore.token = '' }, }) const wrapper = Lock.createLockWrapper(lock) const refreshToken: MiddlewareCallback = async (ctx, next) => { if (ctx.request.skipTokenCheck) return next() const token = await wrapper( () => new Promise((resolve) => { uni.login({ async success(res) { if (res.code) { // 登录获取token接口 prequest('/login', { method: 'post', skipTokenCheck: true, data: { code: res.code }, }).then((res1) => resolve(res1.data.data.token)) // 注意这里根据后台返回的token结构取值 } }, }) }), ) if (ctx.request.header) { // header中统一设置token ctx.request.header['Authorization'] = `Bearer ${token}` } await next() } // 解析响应 const parse: MiddlewareCallback = async (ctx, next) => { await next() // 这里抛出异常,会被错误重试中间件捕获 const { statusCode } = ctx.response if (![200, 301, 302].includes(statusCode)) { // 在这里可以设置toast提示 throw new Error(`${statusCode}`) } } // 实例中间件 prequest.use(refreshToken).use(parse) export default prequest 请求接口统一管理首先,我们在src目录下新建api文件夹,专门存放管理请求接口。新建types.ts用来存放复用的数据结构,例如请求成功返回的数据结构// 假设接口响应通过格式 export interface ApiResp { code: number message: string data: any meta?: { pageSize: number total: number current: number } } 接着按照功能模块进行管理,比如我们有用户相关的接口集合,在api下新建user.ts和user.model.ts两个文件,.model文件用于定义接口interface,这里值得注意的是一个接口对应两个interface,分别定义请求参数及返回的数据结构,这里可以约定统一命名格式为:参数为“Parm”后缀,返回数据为“Resp”后缀,如下示例:user.model.tsimport { ApiResp } from './types' export interface GetUserListParm { position: number } export interface GetUserListResp extends ApiResp { data: GetListData[] } export interface GetUserListData { name: string position: number } user.tsimport * as UserModel from './user.model' import prerequest from '@/utils/request' class UserService { // 获取列表 static getList(params: UserModel.GetListParm) { return prerequest.post<UserModel.GetListResp>( '/list', { params }, ) } } export default UserService 上面文件定义了一个叫做getList的请求方法,GetUserListParm和GetUserListResp分别定义该请求的参数及返回数据结构页面中使用<script setup lang="ts"> import UserService from '@/api/user' async function getData() { const params = { position: 1, } const res = await UserService.getList(params) const { code, data } = res.data if (code === 0) { console.log(data) // 这里访问data会有类型提示 } } getData() </script> 至此,我们已经完成了基本网络请求的封装和使用。而除了普通的网络数据请求,我们还可能遇到上传下载文件的需求,这里我们也一并做统一封装处理。封装上传下载公共方法安装依赖拓展包npm i @prequest/miniprogram-addon -S 通常来说,后台提供的上传接口都是公共的,我们可以在api目录下新建个common.ts文件,里面存放一些公共请求方法,例如上传下载在type.ts中新增内容// 文件上传成功返回数据 export interface UploadResp { code: number msg: string data: { filename: string fileUrl: string } } 修改utils->request.ts,增加createUpload和createDownload的参数声明:declare module '@prequest/types' { interface PQRequest { name?: string url?: string filePath?: string formData?: Common skipTokenCheck?: boolean } } common.ts:import { createUpload, createDownload } from '@prequest/miniprogram-addon' import { UploadResp } from './types' class CommonService { // 上传文件 static uploadFile(filePath: string) { const upload = createUpload(uni.uploadFile, { name: 'imgFile', filePath, formData: { fileName: 'testName' }, }) return upload<UploadResp>('/fileUpload/imgUpload') } // 下载文件 static downloadFile(url: string) { const download = createDownload(uni.downloadFile, { url, }) return download(url) } } export default CommonService 页面中使用示例:import CommonService from '@/api/common' // 选择照片或视频 function chooseMedia(mediaType: 'image' | 'video' = 'image') { uni.chooseMedia({ count: 1, mediaType: [mediaType], sizeType: ['compressed'], maxDuration: 60, success(res) { const path = res.tempFiles.map((item) => item.tempFilePath) uploadFile(path[0]) }, fail() { // $toast("选取图片失败"); }, }) } async function uploadFile(path: string) { const res = await CommonService.uploadFile(path) const { code, data } = res.data if (code === 0) { // 上传成功 } } 总结以上内容完成了对数据请求交互及文件上传下载的封装使用,除了这些,该请求库还提供了其他一些请求处理中间件,例如请求缓存,请求错误重试,具体使用大家移步官方文档查阅即可。值得一提的是,很多时候,我们一加载页面需要并发请求多个接口,很多人习惯直接这么写:onload() { getData1() getData2() getData3() } 这时候我们可以借助Promise 的两个api进行优化:Promise.allSettled: 用于并发请求有多个彼此不依赖的异步任务Promise.all: 用于彼此相互依赖或者在其中任何一个reject时立即结束
Vue
0
0
0
浏览量2011
前端码农

UniApp + Vue 3 + TypeScript 小程序开发实战

使用UniApp结合Vue 3和TypeScript进行小程序开发的实际技能。涵盖UniApp框架的基础知识、Vue 3的新特性、TypeScript的使用以及小程序开发的实战经验。通过实际项目案例和代码演示,学会如何构建高效、可维护的小程序应用
0
0
0
浏览量2364
前端码农

手把手搭建基于React的前端UI库 (一)-- 项目初始化

1、使用dumi创建        dumi是一个开源的负责组件开发及组件文档生成的工具,这里仅为了方便组件库文档展示使用。在最后打包发布时,dumi不参与打包,所以这里使用dumi是可以的。接下来就开始记录实操步骤。首先安装 node,并确保node >= 10.13 && node < 17.6.0。这里作者亲测,node 17.6.0是不兼容的,而作者本地使用的v16.14.2可以完全兼容。在空白的地方新建文件夹mkdir dux-ui && cd ./dux-ui然后执行安装命令,这里我选择站点式的创建方式$ npx @umijs/create-dumi-lib # 初始化一个文档模式的组件库开发脚手架 # or $ yarn create @umijs/dumi-lib $ npx @umijs/create-dumi-lib --site # 初始化一个站点模式的组件库开发脚手架 # or $ yarn create @umijs/dumi-lib --site在根目录下执行命令npm install npm run dev然后项目就跑起来了!(盗用官网的图,仅供参考)2、文件目录脚手架搭建起项目后,可以看到初始文件目录、里为了开发方便,我们把自定义的组件放在单独的components文件夹下:然后修改src下的index文件中组件的导入路径:export { default as Foo } from './components/Foo'; 修改dumi配置文件.umirc.ts,新增menu展示路径:import { defineConfig } from 'dumi'; export default defineConfig({ title: 'test-dumi', favicon: 'https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png', logo: 'https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png', outputPath: 'docs-dist', mode: 'site', // 新增 menus: { // 需要自定义侧边菜单的路径,没有配置的路径还是会使用自动生成的配置 '/components': [ { title: '组件', path: '/components', children: [ // 菜单子项(可选) 'components/Foo/index.md', ], }, ], }, });修改package.json然后执行npm run docs可以看到跑起来了至此,项目就搭建起来了!本章到此结束,下一节会逐步记录各个组件的开发过程。
0
0
0
浏览量2019
前端码农

手把手搭建基于React的前端UI库 (三)-- 基础组件Icon和Button

Icon        图标的设计是个技术活,需要设计出自己专属的风格,就像上一期一开始讲的那样,Style风格设计是三要素之首。基础组件设计,比如按钮是扁平还是立体,输入框是方角还是圆角,加不加阴影等这些,受项目经理、产品和交互的影响会多一点,但是大体来说,应与公司同类产品保持一致,至于如何设计,那就是另外的课题了,不在本文的讨论之列。        本文中的Icon图标使用字体库来完成,通过CSS无侵入式的在一个元素上加入before或者after伪类来实现图标显示,这里不是浏览器的字体,也不是客户电脑里安装的字体,也不是图片或其他方式,而且是以文字的方式显示,这样做相对比较简洁,方便修改,更重要的是利于SEO优化。浏览器兼容性比较好的字体库有WOFF、WOFF2等。字体库兼容性见官方解释。字体库有专门的自定义生成工具,例如fonteditor,可以试用30天;至于字体库,你也可以使用第三方开源的字体库,例如Font Awesome。这里作者就使用Font Awesome的woff字体库,我们打开图形创建工具fonteditor来查看这个文件:     可以看到,每一个Icon都有对应的Code-points,这样我们就能通过CSS来配置字体图标了:// 上图中玻璃杯 .glass:before { content: '\F000'; }设计思路有了,接下来就开始动工!Icon.tsx        在src/components文件夹下,新建文件夹Icon,在该文件夹下新建Icon.tsx:... // 定义接收参数 export interface IconProps { /** 图标类型 */ type: string; /** 是否旋转 */ spin?: boolean; /** 自定义 icon 类名前缀,使用自定义图标库时使用,默认为 icon\_\_ */ prefix?: string; } // 图标控件 const Icon = ({ type, spin, prefix, className, ...rest } => { const configContext = useContext(ConfigContext); const finalPrefix = useMemo(() => prefix || configContext.iconDefaultPrefix || 'icon__', [ configContext.iconDefaultPrefix, prefix ]); return ( <IconWrap className={classnames(prefixCls, `${finalPrefix}${type}`, spin && `${prefixCls}-spin`, className)} spin={spin} {...rest} /> ); }; export default React.memo(Icon);        这里可以看到,我在全局configContext定义了默认的图标类名前缀,默认为icon__,你也可以自定义,只要和CSS样式对应即可。最后返回的是一个IconWrap组件,我们在Icon文件夹下新建style文件夹来放置样式包裹类,style下设置font文件夹来安置我们下载的WOFF文件,并在style同目录下新建icon.css和index.ts:src/components/Icon/style/icon.css:/* 自定义专属的字体类型 */ @font-face { font-family: duiicon; /* src: url(./fonts/duiicon.eot?v=1552285261926); */ src: url(./fonts/fontawesome-webfont.woff) format('woff'); font-weight: 400; font-style: normal; } ... /* 设置字体对应的类 */ [class*=' icon__']:before, [class^='icon__']:before { display: inline-block; } .icon__glass:before { content: '\F000'; } ...src/components/Icon/style/index.ts:import styled from '@emotion/styled'; import { css } from '@emotion/core'; // spinMixin是公共的旋转样式,详见全部代码 const iconSpinMixin = css` ${spinMixin}; line-height: normal; `; export const IconWrap = styleWrap<{ spin?: boolean }>({})( styled('i')(props => { const { spin } = props; return css` vertical-align: baseline; &&& { ${spin && iconSpinMixin}; } `; }) );        从代码中看到,虽然这里面没有用到主题样式的变量,但为了风格统一,这里用styleWrap包裹一下,不给输入参数即可,其返回的仍然是一个回调函数,接受一个函数式组件作为参数,这里传递一个i标签:styled('i'),参数props是组件IconWrap接受的参数,如果有spin旋转,那就加上旋转的样式。        关于前缀的拼接,我这里说一下:classnames(prefixCls, `${finalPrefix}${type}`, spin && `${prefixCls}-spin`, className)第一段是公共样式,这里为dux-ui-icon,可以理解为来自组件库的标识,也方便用户在使用时批量添加样式;第二段就是我们代码中拼接的icon__glass等,用于实际图标显示;第三段是旋转标识;第四段提供了用户自定义class。现在,代码目录结构如下:当然别忘了在src/index导出:export { default as Icon } from './components/Icon';Icon demo我们在同目录下的index.md中写上demo用例:import React from 'react'; // dumi-dux-ui要与你package.json中的name一致 import { Icon } from 'dumi-dux-ui'; import Copy from 'copy-to-clipboard'; // demo start const layout = { style: { marginRight: 10 } }; const Icons = [ 'glass', ]; const TypeDemo = () => ( <div style={{ display: "flex" }}> {Icons.map(item => ( <div key={item} style={{ width: '50px', height: '50px', cursor: 'pointer' }} onClick={() => Copy(item)}> <Icon type={item} {...layout} /> </div> ))} </div> );修改.umirc.ts:... menus: { // 需要自定义侧边菜单的路径,没有配置的路径还是会使用自动生成的配置 '/components': [ { title: '组件', path: '/components', children: [ // 菜单子项(可选) 'components/Icon/index.md', ], }, ], },然后在根目录下执行:npm run docs,打开浏览器,进入localhost:8000/components/icon即可看到:我们的玻璃杯图标加载出来了!F12查看元素,确实是我们想要的加载方式:关于 @emotion中的styled和css方法,可以避免使用外挂css文件,同时组件传递参数更加方便,当然也可以完全不用styled,如下面代码所示,其效果是等价的。详细使用可以查看emotion官网function Content(props: any) { // props的属性需要特殊处理,可传递className属性,通过外挂css实现相同的样式 ... return <i {...props}></i> } export const IconWrap = styleWrap<{ spin?: boolean }>({})(Content);Button        按钮一般会分类别,不同的类别有不同的颜色,我们分为实心,边框空心和禁用三种模式。下面列一个表格说明所有的button样式:类别名称种类名集合StyleTypes 种类['primary', 'warning', 'success', 'error', 'border', 'border-gray']Sizes 大小['sm', 'md', 'lg']Shapes 形状['circle', 'square']Shadowed 阴影['true', 'false']Loading 加载['true', 'false']Disabled 禁用['true', 'false']Block 块级显示['true', 'false']我们根据罗列的类型,开始搭建组件!搭建基础        我们在src/components文件夹下新建文件夹Button,在该目录下新建文件index.tsx:// 定义接受参数,为表格中罗列属性 export interface ButtonProps { /** 按钮类型 */ styleType?: 'primary' | 'warning' | 'success' | 'error' | 'border' | 'border-gray'; /** 按钮尺寸 */ size?: 'sm' | 'md' | 'lg'; /** 形状 */ shape?: 'circle' | 'square'; /** 阴影 */ shadowed?: boolean; /** 主题 */ // theme?: 'dark'; /** 是否加载中 */ loading?: boolean; /** 图标 */ icon?: string | ReactNode; /** 设置原生的 button 上 type 属性 */ type?: string; /** 展示设置为块元素 */ block?: boolean; }同样的,我们使用一个样式类包裹一下原生的button(还是在这个文件):... render() { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { loading, icon, children, ...rest } = this.props; return ( <StyleButton loading={loading} {...rest}> // renderIcon为挂载按钮内图标的函数 {this.renderIcon()} {children} </StyleButton> ); }        接下来去创建StyleButton,与Icon创建一样地,我们在src/components/Button文件夹下新建style文件夹,在该文件夹下新建index.tsx,核心代码在这里:... const Button = ({ loading, styleType, disabled, onClick, block, shadowed, ...rest }) => ( <button disabled={disabled} onClick={!disabled ? onClick : undefined} {...rest} /> ); export const StyleButton = styleWrap<SButtonProps, HTMLButtonElement>({ className: classNameMixin, })(styled(Button)(buttonStyleMixin));        定义一个叫Button的函数式组件,其返回的就是一个原生的button,所以rest参数里你可以传递原生的属性,最后用styleWrap包裹后导出。与Icon不同的是,Button的样式更多,而且还要适配主题。所以这里多了一个类名声明classNameMixin和一个样式函数buttonStyleMixin.classNameMixin:// classNameMixin负责添加各种类名,用于唯一识别和开发者进行定制 const classNameMixin = ({ size, styleType, shape, loading, disabled, checked, }) => classnames( prefixCls, `${prefixCls}-size-${size}`, `${prefixCls}-styletype-${styleType}`, shape && `${prefixCls}-${shape}`, loading && `${prefixCls}-loading`, disabled && `${prefixCls}-disabled`, checked && `${prefixCls}-checked`, );buttonStyleMixin是一个总开关,用于添加各种样式:// buttonStyleMixin const buttonStyleMixin = (props) => { const { theme, loading, shape, checked, block } = props; const { designTokens: DT } = theme; return css` margin: 0; box-sizing: border-box; border-radius: ${DT.T_CORNER_LG}; text-align: center; text-decoration: none; cursor: pointer; outline: none; font-size: ${DT.T_TYPO_FONT_SIZE_1}; white-space: nowrap; ${inlineBlockWithVerticalMixin}; // 块级 ${sizeMixin(props)}; // 大小 ${styleTypeMixin(props)}; // styleType ${shape && shapeMixin(props)}; // 形状 ${loading && loadingMixin(props)}; // 加载中 ${block && css` width: 100%; `}; `; };接下来分别记录上述各个样式变量:sizeMixin:// 通过getHeightBySize拿到主题配置文件中的各个大小 ... return css` height: ${getHeightBySize(DT, size)}; line-height: ${getHeightBySize(DT, size)}; padding: 0 ${getPaddingBySize(DT, size)}; `; ...shapeMixin:... // 目前支持圆形和方形 switch (shape) { case 'circle': return css` border-radius: 50% !important; padding: 0; overflow: hidden; width: ${getHeightBySize(DT, size)}; `; case 'square': return css` padding: 0; overflow: hidden; width: ${getHeightBySize(DT, size)}; `; default: return css``; }loadingMixin的设置也一样,使得鼠标不可点击,暗灰色显示即可。styleTypeMixin用来通过styleType的不同,对应的设置不同的主题配色, 以primary为例:const { // 接受ThemeProvider传递的theme参数 theme: { designTokens: DT }, styleType, disabled, size, shadowed, } = props; ... primary: { background: DT.T_BUTTON_PRIMARY_COLOR_BG_DEFAULT, ':hover,:active': { background: DT.T_BUTTON_PRIMARY_COLOR_BG_HOVER, boxShadow: shadowed ? DT.T_SHADOW_BUTTON_PRIMARY_HOVER : 'none', }, color: DT.T_BUTTON_PRIMARY_COLOR_TEXT_DEFAULT, fill: DT.T_BUTTON_PRIMARY_COLOR_TEXT_DEFAULT, border: 'none', boxShadow: shadowed ? DT.T_SHADOW_BUTTON_PRIMARY : 'none', transition: `${transitionProperty} ${transitionFlat}`, ':link,:visited': { color: DT.T_BUTTON_PRIMARY_COLOR_TEXT_DEFAULT, }, }, ...可以看到,当styleType='primary'时,设置了其背景色、文字颜色、填充色、渐变、过渡、激活时的样式以及被访问后的样式等。如此,一个简单的Button组件封装装好了,我们只是对样式进行了改动,其本质还是返回了一个原生按钮,原来的事件不影响使用。Button demo我们写个demo测试一下。在src/components/Button下新建index.md:import React from 'react'; import { Button } from 'dumi-dux-ui'; // demo start const { StyleTypes } = Button; const ColorDemo = () => { return ( <div> {StyleTypes.map((type) => ( <Button styleType={type} key={type} onClick={() => console.log('clicked')}> Button </Button> ))} </div> ); }; // demo end export default ColorDemo;在.umirc.ts中加入:... menus: { // 需要自定义侧边菜单的路径,没有配置的路径还是会使用自动生成的配置 '/components': [ { title: '组件', path: '/components', children: [ // 菜单子项(可选) 'components/Icon/index.md', 'components/Button/index.md', ], }, ], },根目录下执行npm run docs, 不出意外的话,在localhost:8000/components/button下可以看到:F12测试点击事件:至此,基础组件Icon和Button的记录就到此结束了。下一期会记录布局组件的搭建,敬请期待~
0
0
0
浏览量2016
前端码农

手把手搭建基于React的前端UI库 (四)-- 布局组件

前言        承接上一篇# 手把手搭建基于React的前端UI库 (三)-- 基础组件Icon和Button。我们继续添加组件,本次记录的是布局组件Box和Combine。        本文的代码展示的是主要的核心示意代码,全部代码见仓库:Gitee仓库Box        关于页面布局,最流行的便是flex布局,Box组件便是对flex布局进行的一次封装。废话不多说,上代码。在src/component文件夹下新建文件夹Box,在其中新建文件index.tsx:export interface BoxProps { children?: ReactNode; direction?: 'row' | 'row-reverse' | 'column' | 'column-reverse'; wrap?: 'nowrap' | 'wrap' | 'wrap-reverse'; alignItems?: 'center' | 'flex-start' | 'flex-end' | 'stretch'; alignContent?: 'center' | 'flex-start' | 'flex-end' | 'space-between' | 'space-around'; justifyContent?: | 'center' | 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'stretch'; padding?: number | string; width?: string; height?: string; flex?: string; } class Box extends PureComponent<BoxProps> { render() { const box = <BoxWrap {...this.props} />; return box; } } ...        上边定义了一些接收参数,其与flex的css参数是对应的,我们就每一个具体的参数,在代码里进行实现便可以完成该组件了。接下来实现BoxWrap组件:import styled from '@emotion/styled'; import { css } from '@emotion/core'; export const BoxWrap = styleWrap({ className: prefixCls, })( styled('div')((props: any) => { // 要实现的内容 }), );        架子已经搭起来,老样子,仍然使用styleWrap包裹一层公共样式,内部用一个div承接,中间要实现的内容应该返回要使用的css,这边是核心内容:... const { children, direction, wrap, justifyContent, alignItems, alignContent, span, flex, width, height, padding, } = props; ... return css` box-sizing: border-box; // direction ${direction != null && css` flex-direction: ${direction}; `} // wrap ${wrap != null && css` flex-wrap: ${wrap}; `} // justifyContent ${justifyContent != null && css` justify-content: ${justifyContent}; `} // ... `        可以看到,只是简单加一个css便可以实现,相当简单。需要解释的是children的属性并没有公开声明,而是传递给styled生成的组件后隐式的渲染了。        创建完毕后,在组件外便可以这样实现:<Box direction="column" justifyContent="center"> <div>1</div> <div>2</div> <div>3</div> </Box>Combine        Combine组件用于组合不同的组件,类似Box,仍然是样式为主的组件,是为了布局方便的,对css进行的封装。我们不使用flex,使用一个大div包裹一堆inline-block元素即可。在src/components下新建文件夹Combine,在文件夹下新建文件index.tsx:const Combine = ({ children, sharedProps = {}, spacing = 'smart', separator, ...rest }: CombineProps) => { const { size = 'md' } = sharedProps; let isFirstItem: boolean; return ( <CombineWrap spacing={spacing} {...rest}> {React.Children.map(children, (child) => ( // 遍历child <> // 放置分隔符 {separator && !isFirstItem ? <span className={separatorCls} key="separator"> {separator} </span> : null } // 放置子元素 <div className={itemCls}> {React.cloneElement(child)} ... </div> </> )} </CombineWrap> ) }上边代码中定义了子元素和分隔符的类:export const itemCls = prefixCls + '-item'; export const separatorCls = prefixCls + '-separator';        组件接收spacing参数来控制相关联组件之间的距离,同时还可以设置separator来作为自定义分隔符。来看一下组件CombineWrap的实现:import styled from '@emotion/styled'; import { css } from '@emotion/core'; export const CombineWrap = styledWrap<{ spacing: string }>({ // 接受自定义class className: prefixCls, })( // 生成一个自定义样式包裹的div styled('div')((props) => { const { spacing, theme: { designTokens: DT }, } = props; // spacingMap为designTokens里定义的常量 const space = (DT as any)[spacingMap[spacing]]; return css` display: inline-block; // inlink使得各个子元素平铺排列 vertical-align: middle; // 子元素样式 > .${itemCls} { &:focus { z-index: 2; } &:hover { z-index: 3; } } // 子元素下一个兄弟,分隔符下一个子元素,子元素下一个分隔符 统一都加上左边距,这里就是space的用法 > .${itemCls}+.${itemCls}, > .${separatorCls}+.${itemCls}, > .${itemCls}+.${separatorCls} { margin-left: ${space}; } `; }), ); 关于spacing常量,可以在theme主体中定义常量:T_SPACING_COMMON_XS: '4px', T_SPACING_COMMON_SM: '8px', T_SPACING_COMMON_MD: '12px', T_SPACING_COMMON_LG: '16px', T_SPACING_COMMON_XLG: '20px', T_SPACING_COMMON_XXLG: '24px', T_SPACING_COMMON_XXXLG: '32px',然后我们在项目中使用一下:<Combine> <Button styleType="primary">按钮组展示</Button> <Button>按钮组展示</Button> <Button>按钮组展示</Button> <Button disabled>按钮组展示</Button> </Combine>至此,布局组件Box和Combine已经介绍完毕~  更多组件参照代码仓库:Gitee仓库参考React-componentsAntd之Space和Grid
0
0
0
浏览量1441
前端码农

手把手搭建基于React的前端UI库 (八)-- 模态框

前言我们继续我们的 React 组件库系列。本篇介绍另一种弹出层 - 模态框组件。本文的代码展示的是主要的核心代码,全部代码见仓库:Gitee仓库,组件样式见主页。模态框和上一期讲的popover类似,也是弹出层组件。不同的是,模态框不需要通过trigger触发,通过监听传入的 visible 参数即可确定显示还是隐藏。定义入参一个弹窗,一般需要一个title,一个结尾的按钮放置区footer。还有弹窗的尺寸size,背景区域的遮罩mask,最后还有显示参数visible以及弹窗的各种事件:属性说明visible受控,控制弹出层展示title标题footer脚注size枚举出内置的尺寸mask是否显示遮罩visible控制显示onClose点击关闭按钮的相应事件onOk点击确定按钮的相应事件afterClose关闭之后的事件回调rc-dialog 封装这对定制化弹窗组件,使用rc-trigger似乎有些力不从心,rc-trigger重点在于处理触发的时机,弹出层跟随点击位置,适合轻量级的弹出层;模态框重点在于处理内容展示,需要更好的展示级的库来支持。这里使用专用的rc-dialog:class RcDialogWrap extends Component { static propTypes = { className: PropTypes.string, wrapClassName: PropTypes.string, }; render() { const { className, wrapClassName, ...rest } = this.props as any; return ( <RcDialog wrapClassName={classnames(className, wrapClassName)} {...rest} /> ); } }className 为弹窗内容样式类名,wrapClassName 为弹窗容器样式类名。我们自己定义的弹窗,肯定需要比rc-dialog更多的参数和功能,因此我再使用ModalWrap包裹一下刚刚的组件:export const ModalWrap = styleWrap()( styled(RcDialogWrap)((props) => { const { theme: { designTokens: DT, fontSize }, mask, customStyle, } = props as any; return css` // 弹窗容器 position: fixed; overflow: auto; top: 0; right: 0; bottom: 0; left: 0; z-index: 1010; -webkit-overflow-scrolling: touch; outline: 0; ... ` }) );可以看到,整个弹窗使用fixed定位,并且居中放置,z-index设置比较大,确保在最顶层即可。我们再来看看 ModalWrap是怎么使用的:... <ModalWrap // 默认的一些直传属性,比如 visible 等 {...rest} style={{ // 接受自定义的width样式 width: width, ...style, }} // prefixCls为组件库前缀,这里为 dux-ui-modal prefixCls={prefixCls} // 这里禁用rc-dialog自带的关闭按钮 closable={false} // 传递rc-dialog可识别的动画 animation="slide-fade" maskAnimation="fade" onClose={onClose} title={[ <div key="content" className={`${prefixCls}-title-content`}> {title} </div>, closable && ( <Icon key="close" type="remove_circle" className={`${prefixCls}-close`} onClick={onClose} /> ), ]} // 使用了lodash:_ footer={_.isFunction(footer) ? footer({ locale }) : footer} > // 获取ref <div ref={this.savePopupContainer}></div> // 内容物 {children} </ModalWrap>我们来分析一下上面的代码。animation 和 maskAnimation 为直传给 rc-dialog 的动画配置项;onClose 是关闭事件,由props传入title 封装了传入的 title 元素和 关闭按钮Icon,并付给关闭事件onClosefooter 也是直传参数。可自定义,同时也内置了一组操作脚注:getDefaultFooter = () => { const { onOk, onClose, locale } = this.props as any; return [ <Button size="lg" key="cancel" onClick={onClose} style={{ marginRight: 8 }}> {locale.cancel} </Button>, <Button size="lg" key="confirm" onClick={onOk} styleType="primary"> {locale.confirm} </Button>, ]; };外壳和事件处理看完了,再看看内容物 children 是怎么处理的。我们为了使用便利,对用户暴露出一个内置的modal content 组件,其实就是一个加了样式的 div:export const SContent = styleWrap({ className: contentCls })( styled.div((props) => { const { maxHeight } = props as any; return css` padding: 16px 20px; overflow: auto; max-height: ${maxHeight}; `; }), );一个例子我们来使用以下刚刚写好的组件:... const props = { visible: true } <Modal {...props} onClose={() => this.close()} afterClose={() => console.log('afterClose')} onOk={() => console.log('onOk')} title="this is title" > <Modal.Content>this is content</Modal.Content> </Modal>界面如下:模态框相对比较简单,本文就到此结束啦~~
0
0
0
浏览量1252
前端码农

手把手搭建基于React的前端UI库 (六)-- 打包与发布NPM

前言        经过前几篇文章的讲述,我们的组件库已经初具规模:源码。现在我们讲述一下如何发布到npm上,并且配置一下gitee pages.本文最终部署主页效果:UI库主页。使用Webpack打包静态文件        首先看一下我们项目的目录结构:        可以看到,本组件的静态文件比较少,仅仅在static中放置了Icon的字体样式文件,我们想把静态文件跟源码文件分开放置,方便以后迁移,同时也方便样式的按需引入。所以我们就先来打包static。webpack.config.js1.设置入口entry: { // webpack只负责icon/css打包 icon: path.resolve(__dirname, './static/style/icon.css'), },2.配置静态资源出口的路径 output: { // 与icon相关的js放在scripts filename: 'scripts/[name].[contenthash].min.js', // 静态资源(字体样式文件)放在assets assetModuleFilename: 'assets/[contenthash][ext]', // 发布的包名 library: 'dux-ui', // 使用umd模式,提高兼容性 libraryTarget: 'umd', // 每次build时清空output目录 clean: true, },3.配置插件 - 压缩cssconst cssPlugin = new MiniCssExtractPlugin({ // css放在styles文件夹下 filename: 'styles/[name].min.css', }); ... plugins: [cssPlugin],4.配置loader兼容css和字体文件(重要)module: { rules: [ { test: /static\/style\/icon\.css$/, use: [ // 1、webpack5中, 使用MiniCssExtractPlugin.loader代替style-loader { loader: MiniCssExtractPlugin.loader, }, 'css-loader', ], }, { // 2. webpack5 配置 - 识别字体文件 test: /.(svg|eot|ttf|woff)$/, type: 'asset/resource', }, ], },5.配置打包的文件夹根路径为dist。新建一个 webpack.dist.config.js:const path = require('path'); const config = require('./webpack.config'); config.output.path = path.resolve(__dirname, 'dist'); 运行脚本:NODE_ENV=production npm run build:webpack -- --config webpack.dist.config.js查看项目路径上多出的文件:打包成功!使用Babel打包源文件.babelrc.json{ // import/export 匹配为 script , 其他匹配为 module "sourceType": "unambiguous", "presets": [ [ // 预设,从后往前识别 "@babel/preset-env", { "targets": { "ie": "11", "firefox": "29", "chrome": "30", "safari": "7" }, "spec": true, "loose": true } ], ["@babel/preset-react"], // 识别ts ["@babel/preset-typescript", { "allowDeclareFields": true }] ], "plugins": [ [ // 自动移除语法转换后内联的辅助函数 "@babel/plugin-transform-runtime", { "regenerator": false } ], ["@babel/plugin-transform-typescript", { "allowDeclareFields": true }], // 支持装饰器语法 ["@babel/plugin-proposal-decorators", { "legacy": true }], // 编译类式组件,使用直接赋值方式 ["@babel/plugin-proposal-class-properties", { "loose": true }], // 识别私有变量 ["@babel/plugin-proposal-private-methods", { "loose": true }], "emotion", "lodash", ["babel-plugin-webpack-alias", { "config": "./webpack.config.js", "noOutputExtension": true }], // 从TypeScript生成React interface 或 别名 ["babel-plugin-typescript-to-proptypes"] ] } package.json配置包入口和文件:"main": "lib/index.js", ... "files": [ "README.md", "index.d.ts", "dist/", "lib/" ],运行脚本,打包所有src下的指定文件:babel src/ --extensions '.js,.jsx,.ts,.tsx' -d lib/ --ignore '**/.umi/*','**/__tests__/*'查看打包目录lib,已经有了结果:        可以看到index是有问题的。他自动把src/index.ts打包进去了,这个index.ts是dumi文档自动生成的入口。而往往文档展示的组件与组件库实际拥有的组件数量是不一致的,有一些开发中的项目是不展示在文档中的;同时,如果调整了文档展示的组件,则会造成打包的组件有缺失的风险。所以还是决定单独在生成一次lib下的index.js。build-index.js写一个脚本生成index.js:const fs = require('fs'); const path = require('path'); const child_process = require('child_process'); let js = ''; // 1. 读取所有想要从导出的组件(这里默认全部) const result = fs .readdirSync('./src/components/', { withFileTypes: true, }) .filter((dir) => /^[A-Z]+[a-zA-Z]*$/.test(typeof dir === 'string' ? dir : dir.name)); /** 2. 输出js格式 * import * as InputAll from './components/Input/'; * const Input = Object.assign(InputAll.default, InputAll); * export { Input }; */ result.forEach((dir) => { if (typeof dir !== 'string') dir = dir.name; js += ` import * as ${dir}All from './components/${dir}/'; const ${dir} = Object.assign(${dir}All.default, ${dir}All); export { ${dir} }; `; }); // 3. 写入文件 fs.writeFileSync(path.join(__dirname, 'lib/__index.js'), js); // 4. 使用babel再次打一下包 child_process.execSync('npx babel lib/__index.js --out-file lib/index.js');        执行脚本 node build-index即可。这样生成的lib文件夹下会多一个__index.js文件,但是无伤大雅,如果觉得不够清爽,可以加入以下删除代码:fs.unlink('lib/__index.js', () => {});如此,再次执行脚本:babel src/ --extensions '.js,.jsx,.ts,.tsx' -d lib/ --ignore '**/.umi/*','**/__tests__/*' && node build-index.jslib文件生成完毕~npm发布1.NPM的使用,需要先注册账号,然后在项目根目录下执行npm login:按照图示输入顺序登录,就会看到Logged in字样,说明登入成功!2.生成变更记录项目中安装standard-version / cz-conventional-changelog: yarn add --dev standard-version cz-conventional-changelog配置package.json中的脚本:"commit": "cz"commit你的所有提交。然后打版本(有patch/minor/major三种版本类型): standard-version --release-as patch最后发布 npm publish看到最新的版本号生成就表示发布成功了:最后检查npm个人仓库主页是否有新的版本生成:vite项目使用        我们可以在vite项目中测试一下我们发布的包能否使用。使用如下配置的依赖环境: "scripts": { "start": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "dux-ui": "^1.3.4", "react": "^18.0.0", "react-dom": "^18.0.0" }, "devDependencies": { "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^1.3.0", "vite": "^2.9.9" }执行yarn安装依赖。在vite.config.js中配置引用路径:import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], alias: [ { find: 'dux-ui/dist/styles/icon.min.css', replacement: '/node_modules/dux-ui/dist/styles/icon.min.css' }, { find: 'dux-ui', replacement: '/node_modules/dux-ui/lib/index.js' }, ], })在App.jsx中测试组件:... <> <Icon type="loading" spin></Icon> {StyleTypes.map((type) => ( <Button styleType={type} key={type} onClick={() => console.log('clicked')}> Button </Button> ))} </>启动项目后可以看到效果:Dumi项目Gitee Pages配置首先安装依赖:yarn add --dev gh-pages执行脚本dumi build然后输出到想要导出的文件夹(以docs-dist为例):gh-pages -d docs-dist运行成功后,会看到git仓库多一个分支:在gitee仓库页面,选择对应的服务:选择对应的分支,并且部署即可:打开生成的链接即可看到部署的主页。如果打开后提示静态资源404,则需要进行如下配置:.umirc.ts base: '/dux-ui-react/', publicPath: '/dux-ui-react/',再次从第二步开始执行即可!        至此,基于React的UI库的搭建、部署、基础组件的封装已经结束。后续我会逐步完善组件库,添加更多常用的组件,敬请期待~~
0
0
0
浏览量816
前端码农

手把手搭建基于React的前端UI库 (二)-- 主题配置

 本文的代码展示的是主要的核心代码,全部代码见仓库:Gitee仓库UI库三要素        在决定要写核心组件之前,首先要确定这么几个注意事项: 1. Style风格2. 适配性3. 内容适应性        Style风格可以包含组件库的色彩设计,视觉风格设计,字体,动画效果,主题皮肤和一些基础样式的设计等;适配性则表示要考虑组件库要运行在什么平台,是PC端还是移动端,或者是兼容其他平台等;内容适应性指的是组件包含的功能范围,文案友好度,是否考虑国际化,对开发者是否友好等问题。这些设计风格仁者见仁,在之后的文章中会陆续记录我是如何设计的。        基于上述原则,我们可以先从主题色彩入手。1、ThemeProviderTheme和样式自然是属于全局的,我们用一个provider来承接。ThemeProvider.tsx在src/components下新建一个文件夹ThemeProvider,在该文件夹下新建一个文件ThemeProvider.tsx:class ThemeProvider extends Component { ... }在src/components/ThemeProvider/ThemeProvider.tsx中添加props,其中的theme表示当前的主题:static propTypes = { /** * 自定义主题 */ theme: PropTypes.object.isRequired };        接下来安装一个第三方依赖emotion-theming来提供主题化的解决方案,详细配置可查看其主页,就当做是一个全局的context来管理状态吧。执行指令:npm i --save emotion emotion-theming @emotion/core @emotion/styled然后定义构造器和渲染函数:import React, { Component } from 'react'; import _ from 'lodash'; import PropTypes from 'prop-types'; import { ThemeProvider as EThemeProvider } from 'emotion-theming'; constructor(props: any) { super(props); const theme = props.theme; this.state = { theme }; } ... render() { // eslint-disable-next-line no-unused-vars const { theme: _theme, ...rest } = this.props as any; const { theme } = this.state; return <EThemeProvider theme={theme} {...rest} />; }        留心的朋友可能会注意到,我这里的state中的theme是写在构造器中的,在挂载的时候只执行一次,如果用户想要切换主题,必然会通过props传递新的theme值,我们这里检测props变化:... componentWillReceiveProps(nextProps: any) { const { theme } = nextProps; if (JSON.stringify(theme) !== this.cache) { const mergedTheme = this.getMergedTheme(theme); this.cache = JSON.stringify(theme); this.setState( { theme: mergedTheme }, ); } } getMergedTheme = (theme: string) => { return generateTheme(theme); };        可以看到,加了一个本地的缓存cache来避免重复变更。其中generateTheme是我写的一个增强方法,用于处理前后两个主题对比更新,并加入了一些常用样式,比如fontSize等,如果你觉得没必要,那么你直接设置拿到的theme就行了。        至此,一个提供主题的provider就写好了,现在来给他增加灵魂:配色。这里我写了两种配色(你也可以自己设计,如果你具备设计师的美学素养和专业知识的话):Black和White。designTokens.ts我们在ThemeProvider.tsx同目录新建文件designTokens.ts来放置默认主题配色:/** 默认的主题配色 */ const designTokens = { ... }; export { designTokens };具体的配色变量应全大写,以T开头,仅包含字母、数字、下划线,由于变量太多,这里给出一部分:背景色:多彩色:渐变色池:常用尺寸:        同理,在相同的目录下,新建文件dark.ts, 放入相同的变量,但是色号应该是暗色调的配色,亮色调下的亮色,在暗色调下应该是暗色。比如渐变色池应该是下面这样:这些配色方案,网上也能找到类似的,选择自己喜欢的就行了。有了这些颜色池,我们写一个方法来获取当前的designTokens,还是在同目录下,新建文件useDesignTokens.ts:import { useTheme } from 'emotion-theming'; // ../../style中写了几个辅助的样式,对解释原理没有影响,想看全部代码可以去项目仓库查看 import { defaultTheme, Theme } from '../../style'; const useDesignTokens = () => { // 拿到Themeprovider的theme const theme = useTheme<Theme>(); if (!Object.keys(theme).length) return defaultTheme.designTokens; return theme.designTokens; }; export default useDesignTokens; 这样,就可以在任何一个地方,通过useDesignTokens拿到当前的主题配色了。2、反思        再回到ThemeProvider本身进行思考,总觉得少了点什么。是不是显得功能有点单薄,传值只能传递theme,不够灵活。整个组件库项目要维护的公共变量应该不会只有主题,后来可能还会有国际化什么的,所以,是不是要写一个更通用的Provider来统一处理?        我们在src/components文件夹下,创建一个新的文件夹ConfigProvider,在新创建的文件夹下创建文件ConfigProvider.tsx,写上如下代码:import React, { ReactNode, createContext } from 'react'; import ThemeProvider from '../ThemeProvider'; export interface ConfigProviderProps { /** @ignore */ children: ReactNode; /** 提供时会使用 ThemeProvider 包裹 */ theme?: any; } const ConfigProvider = ({ children, theme, ...rest }: ConfigProviderProps) => { const defaultConfig = {}; const ConfigContext = createContext<any>(defaultConfig); let provider = ( <ConfigContext.Provider value={{ ...defaultConfig, ...rest }}> {children} </ConfigContext.Provider> ); if (theme) provider = <ThemeProvider theme={theme}>{provider}</ThemeProvider>; // 下边可以追加其他情况的Provider ... return provider; }; export { ThemeProvider }; export default ConfigProvider;        这里,我们使用createContext来创建一个context放置全局变量,使用ConfigContext.Provider作为基础全局参数,通过拿到的不同参数来动态叠加转换为其他不同的Provider。当然,context可以自己再单写一个文件来存储,这里就不赘述了。3、如何使用        为了能够在组件中使用的内置主题色,需要将配置对外暴露出来,在src/index.ts文件中删除上一期导入的Foo组件:export { default as Foo } from './components/Foo';然后加入如下代码:export { default as ThemeDark } from './components/ThemeProvider/dark'; export { designTokens as ThemeDefault } from './components/ThemeProvider/designTokens'; export { default as ConfigProvider, ThemeProvider, } from './components/ConfigProvider/ConfigProvider';        主题配置有了,接下来要怎么用呢?用这个Provider把全局App组件包裹一下吗?如果有个别组件想要定制样式呢?似乎不够灵活,作者认为应该封装一个工具类,在需要配置主题色的组加上使用即可。在src目录下新建style文件夹用来放置公共的样式配置,在该文件夹下新增文件styleWrap.tsx:// 引入依赖 import React, { FC, useMemo } from 'react'; import { useTheme } from 'emotion-theming'; import defaultTheme from '../components/ThemeProvider/theme'; ... // 新建一个函数 const styleWrap = () => { ... return Com => { const WithThemeComponent = (props) => { ... const theme = useTheme(); const memoTheme = useMemo( () => (!theme || !Object.keys(theme)?.length ? defaultTheme : theme), [theme], ); const result = { ...props, theme: memoTheme, }; ... const Com = Comp as unknown as FC; // result中带有theme主题参数,在基础业务组件中应接受并处理 return <Com {...result} />; } return WithThemeComponent; } }        可以看到,styleWrap返回的是一个高阶组件WithThemeComponent,在该组件里获取当前主题并使用useMemo节省渲染开销,将结果收集到result里作为属性传递给基础业务组件Com.        接下来记录一下ThemeProvider在组件中的使用方式:... // 1. 引入一种字体 const [theme, setTheme] = useState(ThemeDark); ... // 2. 使用 <ConfigProvider theme={theme}> // 放置被styleWrap包裹的业务组件 ... </ConfigProvider> // 3. 切换主题 setTheme(designTokens);
0
0
0
浏览量2013
前端码农

手把手搭建基于React的前端UI库 (五)-- 基础表单组件

前言        由于疫情原因,被封闭在家中比较烦躁,拖了好久才开始续写UI篇的文章,抱歉。本篇是React组件库的第5篇文章,我们来实现一下Form表单的功能。本文的代码展示的是主要的核心代码,全部代码见仓库:Gitee仓库。另,我已部署了本组件库的文档地址,还请批评指正:dh1992.gitee.io/dux-ui-reac…Input        Input作为最基本的HTML表单组件,肯定是组件库中必不可少的一个组件,同时也是实现起来相对简单的,H5本身就自带了input标签,我们只需要在其基础上自定义自己的样式,并且实现一些事件监听即可。        我们先确定Input要接受的props有哪些。首先输入框肯定要有值,还要能禁用和清除,同时变化还要有检测事件,主要的参数我罗列一下:属性描述value受控值prefix前缀suffix后缀clearable是否可清空size尺寸disabled是否禁用onClear清除事件onFocus聚焦事件onBlur失焦事件onChange变化事件我们在components文件夹下,新建Input文件夹,并在其下新建index.tsx文件:... const Input = ({size, focused, disabled, customStyle, clearable, value}) => { return <SWrap {...{ size, focused, disabled, customStyle }} empty={!value} > <span className={inputWrapCls}> {renderPrefix} <input {...rest} value={value} onChange={onChange} ref={inputRef} onFocus={handleFocus} onBlur={handleBlur} disabled={disabled} /> {renderClear} {renderSuffix} </span> </SWrap> ); }        我用一个SWrap组件包裹了原生的inpu标签,接受用户传入的props属性。看一下SWrap是怎么实现的,仍然是返回一个styleWrap函数,具体的css实现可以看我的源码,这里不多赘述:import styled from '@emotion/styled'; import { css } from '@emotion/core'; ... export const SWrap = styleWrap({})( styled.span((props) => { const { theme: { designTokens: DT }, disabled, size, focused, clearable, customStyle, } = props; return css` /** css实现 */ ... ` }) );我们来看一下每一个属性都是怎么实现其价值的:value/disabled/onChange通过props属性传入以后直接赋值给input标签:value={value}prefix/suffix前后缀我们通过renderPrefix/renderSuffix组件实现,分别放在input的前后:// 如果用户传入了prefix,就返回一个span,加上classname,并给一个onMouseDown的事件,如果不需要这里也可以不要事件。后缀也同前缀一样。 const renderPrefix = useMemo(() => { return ( prefix && ( <span className={inputPrefixCls} onMouseDown={onMouseDown}> {prefix} </span> ) ); }, [onMouseDown, prefix]);clearableclearable是组件自定义的属性,原生input没有自带的清除操作。我们通过后缀组件前边的renderClear组件实现:// 其实与前后缀实现方式一样,不同的是content是固定的一个Icon,并强制赋予handleClear事件 const renderClear = useMemo(() => { if (clearable) { return ( <span className={clearCls} onClick={handleClear} onMouseDown={onMouseDown}> <Icon type="remove_sign" /> </span> ); } }, [clearable, handleClear, onMouseDown]); // 清除value事件 const handleClear = useCallback( (e) => { if (disabled) return; onClear(); const input = inputRef.current; if (!input) return; e.target = input; e.currentTarget = input; const cacheV = input.value; input.value = ''; onChange(e); input.value = cacheV; }, [disabled, onChange, onClear], );        我们设定的清除事件执行顺序是先执行用户传入的onClear,之后缓存value,然后拿到Input标签的ref,设置其value是空字符串,并将当前点击事件的target给Input,调用onChange返给用户。注意最后一行,要重新把input的value赋值回缓存的原值,因为这里并没有任何setState的操作,不应该改变原来的值。用一个onChange事件间隔一下,value值在一个渲染周期内不会渲染两次,所以就造成了被清空的假象。至于清空数据的真实操作,应该放在onChange中。        至此,Input组件的功能实现已经介绍完毕。以此类推,数字输入框NumberInput,只需要在左右两侧个加一个Button来控制增减,在onBlur事件中需要通过正则校验是否为合法的数字即可;文本域Textarea完全可以参照Input,将原生标签换成textarea即可。Radio        H5的input标签虽然有radio属性,但是一般组件库就不太会直接使用了,一方面是原生的方法往往不能满足组件库的需求,另一方面,不想原始的Input输入框,原生标签不能做到组件库需要的样式定制和主题定制。比如我们自定义的radio组件,可以有原生样式,也可以有card模式,而且radio往往都不是一个单独存在,至少得有两个才能满足要求。我们先来定义一下props:属性描述checked是否选中defaultChecked默认是否选中disabled是否禁用onChange点选时的回调valueradio的值styleType样式风格, 可选 'default', 'button', 'tag', 'card', 'text', 'list'size尺寸,可选'sm', 'md', 'lg',styleType 为 card、list 时无效title标题,styleType 为 card 时使用        接下来在src/components下新建文件夹Radio,并在其中新建index.tsx:... renderRadioList(props: any) { /* eslint-disable no-unused-vars */ const { children, checked, onChange, onClick, disabled, ...rest } = props; /* eslint-enable no-unused-vars */ return ( <RadioListWrap checked={checked} disabled={disabled} {...rest} onClick={(...args: any) => this.onClick(props, { ...args })} > <RadioIcon checked={checked} disabled={disabled} /> {children != null && <span className={contentCls}>{children}</span>} </RadioListWrap> ); }        RadioListWrap仍然是用styleWrap包裹的包含样式的div,内容物我们放一个图标RadioIcon接收最重要的属性checked,后边用一个span包裹传入的children,使用时可以这样写:<Radio checked>checked</Radio>组件RadioIcon内部放一个实心圆形的Icon,SIconWrap仍然是一个div:const RadioIcon = (props: { checked?: boolean; disabled?: boolean }) => { return ( <SIconWrap {...props}> <Icon className={iconCls} type="whitecircle" /> </SIconWrap> ); };在SIconWrap样式定义里,通过判断是否checked来控制样式高亮:${checked && css` // checked时,SIconWrap加一个主题色的边框 &.${iconWrapCls} { color: ${DT.T_COLOR_LINE_PRIMARY_DEFAULT}; border-color: ${DT.T_COLOR_LINE_PRIMARY_DEFAULT}; } // checked时,Icon字体加上主题色 .${iconCls} { visibility: visible; opacity: 1; fill: ${DT.T_COLOR_TEXT_PRIMARY_DEFAULT}; } `}基本结构讲解完了,我们来看加上样式美化后的效果:        可以看到,除了模拟原生样式外,还可以模拟按钮、标签和列表样式,实现方式同上,只需要将RadioWrap内的Icon替换成其他的组件即可。表单组件比较多,这里只是讲述了基本的几个实现,为写表单组件提供一个思路,更多组件可以参见组件库主页Form        讲完基础表单组件,我们家实现一下表单组件的布局,就叫Form。H5原生的组件是有form的,我们这里需要用到其submit方法,所以就使用原生的form,并对其进行样式封装。        表单容器,必须有一个根组件Form,内部有至少一个formitem,每一个formitem内包含一个基础表单输入组件,定义不同的name。在src/components下新建Form文件夹,并在其下新建Form.tsx,Item.tsx。先看Form.tsx:const Form = ({ onSubmit, ...rest }: any) => { const { preventFormDefaultAction } = useContext(ConfigContext); const handleSubmit = React.useCallback( (e) => { if (e) { e.preventDefault(); e.stopPropagation(); } onSubmit && onSubmit(e); }, [onSubmit], ); return ( <FormWrap onSubmit={preventFormDefaultAction ? handleSubmit : onSubmit} {...rest} /> ); };        表单容器,必须有一个根组件Form用于容纳所有的输入元素,触发原生的submit事件;内部children有至少一个formitem,每一个formitem内包含一个基础表单输入组件,定义不同的name,formitem应该遵循左右布局格式,使用float或者flex都可以实现。我们看传入的参数onSubmit,在组件FormWrap的回调onSubmit中调用。我们来看一下FormWrap,他返回的就是一个加了样式的原生form标签:export const FormWrap = styleWrap<{ size: string }>({ className: prefixCls, })( styled('form')((props) => { return css` font-size: 12px; // 给下属的每一个item加样式 .${itemCls} { margin-bottom: 16px; &:last-child { margin-bottom: 0; } } `; }), );        我们再来看内容物Item.tsx的实现:... return ( <ItemWrap> <LabelWrap {...labelCol}> {label} {required && <RequiredLabel>*</RequiredLabel>} {help && <CommentWrap>{help}</CommentWrap>} </LabelWrap> <ControllerWrap {...controllerCol}> {children} <RenderTip tip={tip} status={status} /> </ControllerWrap> </ItemWrap> );        ItemWrap,LabelWrap,ControllerWrap,用于表单条目左右布局的实现:// ItemWrap 使用12栅格布局 export const ItemWrap = styleWrap<{ size: string }>({ className: prefixCls, })( styled('form')((props) => { return css` font-size: 12px; display: flex; // 给下属的每一个label加样式,样式类名从LabelWrap获取 .${prefixCls} > .${itemLabelCls} { flex: ${props.labelCol.span || 6} } // 给下属的每一个controller受控区域加样式,样式类名从ControllerWrap获取 .${prefixCls} > .${itemControllerCls} { flex: ${props.controllerCol.span || 6} } `; }), );        我们来看看放组件后的效果(主页demo):        总结一下,表单组件需要放在原生form标签内,使用栅格布局,左侧放置label,右侧放置具体的输入组件;本文章介绍了Input和Radio如何来封装,其他组件也都是大同小异,由于篇幅限制,读者可以去主页查看效果或者自己去npm安装一下实施效果(相关配置可查看源码README):npm install --save dux-ui
0
0
0
浏览量1820
前端码农

手把手搭建基于React的前端UI库 (七)-- 弹出层组件

前言        我们继续我们的 React 组件库系列。本篇介绍弹出层 PopOver以及 tooltip 的实现。本文的代码展示的是主要的核心代码,全部代码见仓库:Gitee仓库,组件样式见主页。定义入参首先,还是先确定一下一个弹出层都需要什么参数来控制呢。相信大家也都用过Tooltip之类的组件,肯定有控制显示隐藏的属性,还有展开方向等,我这里总结了一下基础的属性:属性说明visible受控,控制弹出层展示trigger触发方式placement展开方向onVisibleChange显示事件popup弹出层的元素zIndex层级popupClassName弹出层类名rc-triggerrc-trigger是一套流行的开源库,集成了各种触发方式判断、弹出层渲染等功能。本组件库就使用该第三方库。先引入一下:import RcTrigger from 'rc-trigger';然后声明一个rcTrigger的组件:class RcTriggerWrap extends Component { static propTypes = { className: PropTypes.string, popupClassName: PropTypes.string, trueClassName: PropTypes.string, }; render() { const { className, popupClassName, trueClassName, ...rest } = this.props as any; return ( <RcTrigger className={trueClassName} popupClassName={classnames(className, popupClassName)} {...rest} /> ); } }接下来,用styled样式包裹一下,便于我们自定义样式:import styled from '@emotion/styled'; export const PopoverWrap = styled(RcTriggerWrap)` /* 放置css */ `;这个PopoverWrap便是我们之后要用的弹出层组件的外壳了。弹出层的组件,其实只需要对外暴露 action 、placement 和 popup属性就行了,所谓在导出时,我们需要再封装一下。在组件库的index.tsx文件中,可以这样使用:... return <PopoverWrap action={trigger} popupPlacement={placement} popupTransitionName={ transitionName || animation ? animationPrefixCls + '-' + animation : null } popupVisible={popup == null ? false : this.state.visible} popup={popup} onPopupVisibleChange={this.onVisibleChange} popupClassName={className} getPopupContainer={getPopupContainer} > {children} </PopoverWrap>action对应的是rc-trigger的action属性,trigger通过props接受。指的是触发弹出的操作,可选项有 'hover','click','focus','contextMenu',是一个数组参数,默认为:['hover']popupPlacement对应的是rc-trigger的popupPlacement属性,通过props接受。是一个枚举项,枚举定义如下:placement?: | 'topLeft' | 'top' | 'topRight' | 'bottomLeft' | 'bottom' | 'bottomRight' | 'leftTop' | 'left' | 'leftBottom' | 'rightTop' | 'right' | 'rightBottom';popupTransitionName对应rc-trigger的popupTransitionName属性,定义弹出的动画,是可选项。可通过props接受animation属性来定义。目前,rc-trigger只支持上下方向的动画,其内部使用了rc-animation,可选项如下: "fade" | "zoom" | "bounce" | "slide-up"popupVisible对应rc-trigger的popupVisible属性,在用户传了popup后,通过props.visible属性来控制,若用户没有传popup,强行置为falsepopup对应rc-trigger的popup属性,是最核心的参数,可传一个Element元素,用于弹出层的展示。onPopupVisibleChange属性同上,在popup弹出时触发。popupClassName对应rc-table的popupClassName,给弹出层加一个类名。getPopupContainer对应rc-table的getPopupContainer,可以自定义一个弹出层容器,非必选项。最后不能少了参数 children, 通过hover或者click这个children才能触发弹出层的变化。rc-trigger组件可以隐性的传一个同名的children,文档里虽然没有写明,但是react组件默认都可以这样传递。定义样式对于弹出层组件的外壳PopoverWrap,还记得上边预留的样式的空白了吗,我们这里分情况讨论。先把外壳设置为绝对定位,这是为了配合rc-trigger的top和bottom样式,另外,想要弹出层跟随点击区域,也必须绝对定位,父元素使用相对定位。const _prefixCls = 'dux-ui'; // 通过导入全局变量获得 export const prefixCls = _prefixCls + '-popover'; export const animationPrefixCls = prefixCls + '-animation'; export const PopoverWrap = styled(RcTriggerWrap)` &.${prefixCls} { // 弹出层绝对定位 position: absolute; z-index: 1030; display: block; &-hidden { display: none; } } ... `;在控制台里可以看到:rc-trigger会在传入的popupClassName后拼接一个带有方向参数的类名,由此,我们来定义各自的样式,以左下和左上为例:&-enter-active.${prefixCls}-placement-bottomLeft, &-appear-active.${prefixCls}-placement-bottomLeft { animation-name: ${slideUpIn}; animation-play-state: running; } &-enter-active.${prefixCls}-placement-topLeft, &-appear-active.${prefixCls}-placement-topLeft { animation-name: ${slideDownIn}; animation-play-state: running; }分别配置使用不同的动画,如果不想自定义动画,也可以不设置。如果props中声明了动画类型,则会有一个这样的类名:以fade为例,可以这样声明css:const animationDuration = '0.1s'; ... &-fade { &-enter, &-appear, &-leave { animation-duration: ${animationDuration}; animation-fill-mode: both; } &-enter, &-appear { animation-name: ${fadeIn}; } &-leave { animation-name: ${fadeOut}; } }至于动画的具体写法,有很多种,这里以淡入淡出为例,实现一种作为参考:export const fadeIn = keyframes` from { opacity: 0; } to { opacity: 1; } `; export const fadeOut = keyframes` from { opacity: 1; } to { opacity: 0; } `;一个例子有了这些基本的配置,一个弹出层组件就能跑起来了。测试:const Popup = () => ( <div style={{ height: 30, border: '1px solid #ddd', background: '#fff' }}>This is a popup</div> ); <Popover trigger={['hover']} popup={<Popup />}> <Content>{'hover'}</Content> </Popover>预览效果:成功!进阶:做一个TooltipTooltip的底层,其实就是一个popover,我们来实现一下。直接看return的结果:<Popover placement={placement} popupClassName={...} popup={popup} />Tooltip不同于通用弹出层的地方在于popup的内容,他会有一个类似漫画对话框的小箭头。那我们来定义一下popup:export const arrowWrapCls = popoverPrefixCls + '-span' + '-arrow-wrap'; export const arrowCls = popoverPrefixCls + '-span' + '-arrow-inner'; const popup = useMemo(() => { return ( <TooltipWrap> {arrow && ( <Arrow className={arrowWrapCls}> <ArrowInner className={arrowCls} /> </Arrow> )} <ContentWrap customStyle={customStyle}> {popup} </ContentWrap> </TooltipWrap> ); }, [popup, arrow, customStyle, theme]);可以看到其接受了几个新的参数,我们一个个来分析。arrow: 一个bool值,控制是否显示箭头。默认是没有的:我们来定义箭头的样式,以 top 的方向为例:const arrowMixin = css` display: inline-block; position: absolute; width: 0; height: 0; border-width: 0; border-color: transparent; border-style: solid; `; export const Arrow = styled('span')` ${arrowMixin}; `; export const ArrowInner = styled('span')` ${arrowMixin}; `; .${arrowWrapCls}, .${arrowCls} { margin-left: -${arrowWidth}; border-width: ${arrowWidth} ${arrowWidth} 0 ${arrowWidth}; border-bottom-color: transparent; border-left-color: transparent; border-right-color: transparent; } .${arrowWrapCls} { bottom: -${arrowWidth}; } .${arrowCls} { bottom: ${borderWidth}; }可以看到,用一个绝对定位的span包裹,设置宽高是0,然后设置其上、右、左边框宽度后再设置垂直方向的反方向,即下、左、右边框为透明色,模拟一个箭头出来,效果图如下:同理,可设置其他方向的箭头样式~~popup: 是弹出层元素。TooltipWrap和ContentWrap是styled包裹组件,本质是个div,可以自定义样式。至此,弹出层组件也讲完辣~~
0
0
0
浏览量596
前端码农

React前端UI库搭建实战

手把手教授如何搭建基于React的前端UI库。从项目初始化、组件设计、样式处理到文档编写,涵盖了搭建UI库的全过程。深入了解React组件开发、工程化配置以及UI库发布流程。通过实际演练,具备构建自己前端UI库的实际能力,适用于前端开人员、React爱好者和UI设计师
0
0
0
浏览量2110
前端码农

Rust学习笔记之基础概念

要么我说了算,要么我什么也不说 -- 拿破仑今天,我们继续Rust学习笔记的探索。我们来谈谈关于基础概念的相关知识点。如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。你能所学到的知识点变量与可变性 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️ 数据类型 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️Rust中函数 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️ 流程控制 推荐阅读指数 ⭐️⭐️⭐️⭐️好了,天不早了,干点正事哇。变量与可变性在Rust中变量默认是不可变的。当一个变量是不可变的时,一旦它被绑定到某个值上面,这个值就再也无法被改变。fn main(){ let x =7; x = 8; } 保存并通过命令cargo run来运行代码,会提示如下错误:这里提示我们cannot assign twice to immutable variable x(不能对不可变量x进行二次赋值)变量默认是不可变的,但你可以通过在声明的变量名称前添加mut关键字来使其可变。fn main() { let mut x =7; println!("x的值是:{}",x); x = 8; println!("x的值是:{}",x); } 保存并通过命令cargo run来运行代码,输出结果如下:x的值是 7x的值是 8设计一个变量的可变性还需要考量许多因素。 当你在使用某些重型数据结构时,适当地使用可变性去修改一个实例,可能比赋值和重新返回一个新分配的实例更有效率 当数据结构较为轻量的时候,采用更偏向函数式的风格,通过创建新变量来进行赋值,可能会使代码更加易于理解。变量和常量之间的不同变量的不可变性可能会让你联想到另外一个常见的编程概念:常量。但是,常量和变量之间还存在着一些细微的差别不能用mut关键字来修饰一个常量。 常量不仅是默认不可变的,它还总是不可变的 使用const关键字而不是let关键字来声明一个常量 在声明的同时,必须显示地标注值的类型 常量可以被声明在任何作用域中,甚至包括全局作用域。 这在一个值需要被不同部分的代码共同引用时十分有用 只能将常量绑定到一个常量表达式上,而无法将一个函数的返回值或其他需要在运行时计算的值绑定在常量上。下面是声明常量的例子,数值100被绑定到了常量MAX_AGE上。在Rust中,约定俗成地使用下划线分隔的全大写字母来命令一个常量fn main() { const MAX_AGE:u32 = 100; } 遮蔽在Rust中,一个新的声明变量可以覆盖掉旧的同名变量,我们把这一个现象描述为:第一个变量被第二个变量{遮蔽|Shadow}了。这意味着随后使用这个名称时,它指向的将会是第二个变量。fn main() { let x =5; let x = x + 1; let x = x * 2; println!("x的值为:{}",x) } 这段程序首先将x绑定到值为5上。随后它又通过重复let x =语句遮蔽了第一个x变量,并将第一个x变量值加上1的运行结果绑定到新的变量x上,此时x的值是6。第三个let语句同样遮蔽了第二个x变量,并将第二个x变量值乘以2的结果12绑定到第三个x变量上。通过使用let,可以将对这个值执行一系列的变换操作,并允许这个变量在操作完成后保持自己的不可变性。遮蔽机制与mut的一个区别在于:由于重复使用let关键字会创建出新的变量,所以可以在复用变量名称的同时改变它的类型。fn main() { let spaces:&str = "abc"; let spaces:usize= spaces.len(); } 第一个 spaces 变量是一个字符串类型,第二个 spaces 变量是一个数字类型。数据类型Rust中每一个值都有其特定的数据类型,Rust会根据数据的类型来决定应该如何处理它们。我们来介绍两种不同的数据类型子集:{标量类型|Scalar}和{复合类型|Compound}。Rust是一门静态类型语言,这意味着它在编译程序的过程中需要知道所有变量的具体类型。在大部分情况下,编译器都可以根据我们如何绑定、使用变量的值来自动推导出变量的类型。但是,在某些时候,当发生数据类型的转换时候,就需要显示地添加一个类型标注。下面的test变量是将String类型转换为数值类型。let test:u32 = "42".parse().expect("非数值类型") 标量类型标量类型是单个值类型的统称。在Rust中内建了4种基础的标量类型:整数浮点数布尔值字符整数类型整数是指那些没有小数部分的数字。在Rust中存在如下内建整数类型,每一个长度不同的值都存在有符号和无符号两种变体。长度有符号无符号8-biti8u816-biti16u1632-biti32(Rust默认)u3264-biti64u64archisizeusize每一个整数类型的变体都会标明自身是否存在符号,并且拥有一个明确的大小。有符号和无符号代表了一个整数类型是否拥有描述负数的能力。换句话说,对于有符号的整数类型来讲,数值需要一个符号来表示当前是否为正对于无符号的整数来讲,数值永远为正,不需要符号对于一个位数为n的有符号整数类型,它可以存储从-(2n-1)到(2n-1-1)范围内的所有整数。 而对于无符号整数类型而言,则可以存储从0到(2n-1)范围内的所有整数。除了指明位数的类型,还有isize和usize两种特殊的整数类型,它们的长度取决于程序运行的目标平台。在64位架构上,它们就是64位的在32位架构上,它们就是32位的Rust对于整数字面量的默认推导类型i32通常就是一个很好的选择:它在大部分情形下都是运算速度最快的那一个。当Rust发生整数溢出时候,会执行二进制补码环绕。也就是说,任何超出类型最大值的整数都会被环绕为类型最小值。浮点数类型Rust还提供了两种基础的浮点数类型,浮点数也就是带小数点的数字。这两种类型是f32和f64,它们分别占用了32位和64位空间。在Rust中,默认会将浮点数字面量的类型推导为f64。Rust的浮点数使用了IEEE-754标准来进行表述,f32和f64类型分别对应这标准中的单精度和双精度浮点数。布尔类型Rust的布尔类型只拥有两个可能的值true和false,它只会占据单个字节的空间大小。使用bool来表示一个布尔类型。fn main(){ let t = true; let f:bool = false; } 字符类型在Rust中,char类型被用于描述语言中最基础的单个字符。fn main(){ let c = 'a'; } char类型使用单引号指定,字符串使用双引号指定。在Rust中char类型占4字节,是一个Unicode标量值,这意味着它可以表示比ASCII多的字符内容。复合类型{复合类型|Compound}可以将多个不同类型的值组合为一个类型。在Rust提供了两个内置的基础复合类型:{元组|Tuple}和{数组|Array}元组类型元组可以将其他不同类型的多个值组合进一个复合类型中。元组还拥有一个固定的长度:你无法在声明结束后增加或减少其中的元素数量。为了创建元组,需要把一系列的值使用逗号分隔后放置到一对圆括号中。元组每个位置都有一个类型,这些类型不需要是相同的。fn main(){ let tup:(i32,f64,u8) = (500,7.8,1); } 由于一个元组也被视为一个单独的复合元素,所以这里的变量tup被绑定到了整个元组上。为了从元组中获得单个的值,可以使用模式匹配来{解构|Destructuring}元组fn main(){ let tup:(i32,f64,u8) = (500,7.8,1); let (x,y,z) = tup; } 除了解构,还可以通过索引并使用点号(.)来访问元组中的值。fn main(){ let tup:(i32,f64,u8) = (500,7.8,1); let firstValue = x.0; let secondValue = x.1; } 数组类型我们同样可以在数组中存储多个值的集合。与元组不同,数组中每一个元素都必须是相同类型。 Rust中数组拥有固定的长度,一旦声明就再也不能随意更改大小。fn main(){ let a = [1,2,3,4,5]; } 当然,Rust标准库也提供了一个更加灵活的{动态数组|Vector}:它是一个类似于数组的集合结构,但它允许用户自由的调整数组的长度。这个我们后面的章节会有详细介绍。为了写出数组的类型,你可以使用一对方括号,并在方括号中填写数组内所有元素的类型,一个分号及数组内元素的数量。fn main(){ let a:[i32;5] = [1,2,3,4,5]; } 另外还有一种更简便的初始化数组的方式。在方括号中指定元素的值并接着填入一个分号及数组的长度。fn main(){ let a =[3;5]; } 以a命令的数组将会拥有5个元素,而这些元素全部拥有相同的初始值3。访问数组的元素数组是一整块分配在栈上的内存组成,可以通过索引来访问一个数组中所有元素。fn main(){ let a =[1,2,3,4,5]; let frist = a[0]; let second = a[1]; } 非法的数组元素访问存在如下代码fn main() { let a = [1,2,3,4,5]; let index = 10; let item = a[index]; } 使用cargo run运行这段代码,会发现程序顺利的通过编译,会在运行时因为错误而奔溃退出:实际上,每次通过索引来访问一个元素时,Rust都会检查这个索引是否小于当前数组的长度。假如索引超出了当前数组的长度,Rust就会发生panic。函数Rust代码使用{蛇形命名法|Snake Case} 来作为规范函数和变量名称的风格。蛇形命名法只使用小写的字母进行命名,并以下画线分隔单词。fn main() { another_function() } fn another_function(){ println!("函数调用") } 在Rust中,函数定义以fn关键字开始并紧随函数名称与一对圆括号,还有一对花括号用于标识函数体开始和结尾的地方。可以使用函数名加圆括号的方式来调用函数。Rust不关心在何处定义函数,只要这些定义对于使用区域是可见的既可。函数参数还可以在函数声明中定义{参数|Argument},它们是一种特殊的变量,并被视作函数签名的一部分。当函数存在参数时,你需要在调用函数时为这些变量提供具体的值。fn main() { another_function(5) } fn another_function(x:i32){ println!("传入函数的变量为:{}",x) } 在函数签名中,你必须显示地声明每个参数的类型。函数体重的语句和表达式函数体由若干语句组成,并可以以一个表达式作为结尾。由于Rust是一门基于表达式的语言,所以它将{语句|Statement}和{表达式|Expression}区别为两个不同的概念。语句指那些执行操作但不返回值的指令表达式是指会进行计算并产生一个值作为结果的指令使用let关键字创建变量并绑定值时使用的指令是一条语句。fn main(){ let y = 6; } 这里的函数定义同样是语句,甚至上面整个例子本身也是一条语句。语句不会返回值因此,在Rust中,不能将一条let语句赋值给另一个变量。如下代码会产生编译时错误。fn main(){ let x = (let y =6); } 与语句不同,表达式会计算出某个值来作为结果。另外,表达式也可以作为语句的一部分。调用函数是表达式调用宏是表达式用创建新作用域的花括号({})同样也是表达式fn main(){ let x =5; ①let y = {② let x =3; ③ x + 1 }; } 表达式②是一个代码块,它会计算出4作为结果。而这个结果会作为let语句①的一部分被绑定到变量y上。函数的返回值函数可以向调用它的代码返回值。需要在箭头符号(->)的后面声明它的类型。在Rust中,函数的返回值等同于函数体的最后一个表达式。可以使用return关键字并指定一个值来提前从函数中返回但大多数函数都隐式地返回了最后的表达式fn five() ->i32{ 5 } fn main() { let x = five(); println!("子函数返回的值为:{}",x) } 如上的代码中,five函数的返回值类型通过-> i32被指定了。five函数中的5就是函数的输出值,这也就是它的返回类型会被声明为i32的原因。控制流在Rust中用来控制程序执行流的结构主要是if表达式和循环表达式。if表达式if表达式允许根据条件执行不同的代码分支。 fn main() { let number = 3; if number <5 { println!("满足条件") }else{ println!("不满足条件") } } 所有的if表达式都会使用if关键字来开头,并紧随一个判断条件。其后的花括号中放置了条件为真时需要执行的代码片段。if表达式中与条件相关联的代码块被称为{分支|Arm}条件表达式必须产生一个bool类型的值,否则会触发编译错误在Rust中不会自动尝试将非布尔类型的值转换为布尔类型。必须显示地在if表达式中提供一个布尔类型作为条件。在let 语句中使用if由于if是一个表达式,所以可以在let语句的右侧使用它来生成一个值。 fn main() { let condition = true; let number = if condition { 5 } else { 6 }; println!("number的值为:{}",number) } 代码块输出的值就是其中最后一个表达式的值。另外,数字本身也可以作为一个表达式使用。上面的例子中,整个if表达式的值取决于究竟哪一个代码块得到执行。所有if分支可能返回的值都必须是一种类型。使用循环重复执行代码Rust提供了多种{循环|Loop}工具。一个循环会执行循环体中的代码直到结尾,并紧接着回到开头继续执行。Rust提供了3种循环loopwhilefor使用loop重复执行代码可以使用loop关键字来指示Rust反复执行某一块代码,直到显示地声明退出为止。fn main() { loop { println!("重复执行") } } 运行这段程序时,除非手动强制退出程序,否则重复执行字样会被反复输出到屏幕中。从loop循环中返回值loop循环可以被用来反复尝试一些可能会失败的操作,有时候也需要将操作的结果传递给余下的代码。我们可以将需要返回的值添加到break表达式后面,也就是用来终止循环表达式后面。fn main() { let mut counter = 0; let result = loop { counter +=1; if counter ==10 { break counter *2; } }; println!("result的值为:{}",result) } 上面的代码中,当counter值为10时候,就会走break语句,返回counter *2。并将对应的值返回给result。while 条件循环另外一种常见的循环模式是在每次执行循环体之前都判断一次条件,假如条件为真则执行代码片段,假如条件为假或执行过程中碰到break就退出当前循环。fn main() { let mut counter = 3; while counter!=0{ println!("{}",counter); counter = counter -1; } } 使用for来循环遍历集合fn main() { let a = [1,2,3,4,5]; for element in a.iter() { println!("当前的值为{}",element) } } for循环的安全性和简洁性使它成为Rust中最为常用的循环结构。
0
0
0
浏览量1612
前端码农

Rust学习之泛型、trait 与生命周期

泛型大补汤{泛型|generics}是一种编程语言特性,它允许在编写代码时使用抽象类型,而不是具体的类型。这使得代码更加灵活和可重用,因为它可以适用于多种不同的数据类型。我们可以表达泛型的属性,比如他们的行为或如何与其他泛型相关联,而不需要在编写和编译代码时知道他们在这里实际上代表什么。下面展示各种OOP编程语言中,定义泛型的方式C++:template<typename T> void swap(T& a, T& b) { T temp = a; a = b; b = temp; } JAVA:public class GenericClass<T> { private T data; public GenericClass(T data) { this.data = data; } public T getData() { return data; } public void setData(T data) { this.data = data; } } TS:function identity<T>(arg: T): T { return arg; } 各自优缺点C++的泛型表达使用了模板,可以在编译时进行类型检查,提高了代码的安全性和效率。但是模板的语法较为复杂,需要掌握一定的模板元编程技巧。JAVA的泛型表达使用了泛型类和泛型方法,可以在运行时进行类型检查,提高了代码的灵活性和可读性。但是泛型类和泛型方法的语法较为繁琐,需要掌握一定的泛型编程技巧。TS的泛型表达使用了类型变量,可以在编译时进行类型检查,提高了代码的安全性和可读性。但是类型变量的语法较为简单,可能会导致类型推断不准确。RustRust使用处理trait,这是一个定义泛型行为的方法。trait 可以与泛型结合来将泛型限制为拥有特定行为的类型,而不是任意类型。{生命周期|lifetimes},它是一类允许我们向编译器提供引用如何相互关联的泛型。Rust 的生命周期功能允许在很多场景下借用值的同时仍然使编译器能够检查这些引用的有效性。泛型数据类型可以使用泛型为函数签名或结构体等项创建定义,这样它们就可以用于多种不同的具体数据类型。在函数定义中使用泛型当使用泛型定义函数时,本来在函数签名中指定参数和返回值的类型的地方,会改用泛型来表示。采用这种技术,使得代码适应性更强,从而为函数的调用者提供更多的功能,同时也避免了代码的重复。如下展示了两个函数,它们的功能都是寻找 slice 中最大值。fn largest_i32(list: &[i32]) -> i32 { let mut largest = list[0]; for &item in list.iter() { if item > largest { largest = item; } } largest } fn largest_char(list: &[char]) -> char { let mut largest = list[0]; for &item in list.iter() { if item > largest { largest = item; } } largest } fn main() { let number_list = vec![34, 50, 25, 100, 65]; let result = largest_i32(&number_list); println!("最大的数为{}", result); assert_eq!(result, 100); let char_list = vec!['y', 'm', 'a', 'q']; let result = largest_char(&char_list); println!("最大的字符为{}", result); assert_eq!(result, 'y'); } largest_i32 函数,它用来寻找 slice 中最大的 i32。largest_char 函数寻找 slice 中最大的 char。因为两者函数体的代码一致,我们可以定义一个函数,再引进泛型参数来消除这种重复。为了参数化新函数中的这些类型,我们也需要为类型参数取个名字,道理和给函数的形参起名一样。任何标识符都可以作为类型参数的名字。这里选用 T,因为传统上来说,Rust 的参数名字都比较短,通常就只有一个字母,同时,Rust 类型名的命名规范是{骆驼命名法|CamelCase}。T 作为 “type” 的缩写是大部分 Rust 开发者的首选。如果要在函数体中使用参数,就必须在函数签名中声明它的名字,好让编译器知道这个名字指代的是什么。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。为了定义泛型版本的 largest 函数,类型参数声明位于函数名称与参数列表中间的尖括号 <> 中像这样(熟悉TS的小伙伴,是不是有种似曾相识的感觉)fn largest<T>(list: &[T]) -> T {} 函数 largest 有泛型类型 T。它有个参数 list,其类型是元素为 T 的 slice。largest 函数的返回值类型也是 T。largest 函数在它的签名中使用了泛型,统一了两个实现。fn largest<T>(list: &[T]) -> T { let mut largest = list[0]; for &item in list.iter() { if item > largest { largest = item; } } largest } fn main() { let number_list = vec![34, 50, 25, 100, 65]; let result = largest(&number_list); println!("最大的数为{}", result); let char_list = vec!['y', 'm', 'a', 'q']; let result = largest(&char_list); println!("最大的字符为{}", result); } 如果现在就编译这个代码,会出现如下错误:error[E0369]: binary operation `>` cannot be applied to type `T` --> src/main.rs:5:12 | 5 | if item > largest { | ^^^^^^^^^^^^^^ | = note: an implementation of `std::cmp::PartialOrd` might be missing for `T` 注释中提到了 std::cmp::PartialOrd,这是一个 trait。这个错误表明 largest 的函数体不能适用于 T 的所有可能的类型。因为在函数体需要比较 T 类型的值,不过它只能用于我们知道如何排序的类型。结构体定义中的泛型同样也可以用 <> 语法来定义结构体,它包含一个或多个泛型参数类型字段。struct Point<T> { x: T, y: T, } fn main() { let integer = Point { x: 5, y: 10 }; let float = Point { x: 1.0, y: 4.0 }; } 其语法类似于函数定义中使用泛型。首先,必须在结构体名称后面的尖括号中声明泛型参数的名称。接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。注意 Point<T> 的定义中只使用了一个泛型类型,这个定义表明结构体 Point<T>对于一些类型 T 是泛型的,而且字段 x 和 y 都是 相同类型的,无论它具体是何类型。struct Point<T> { x: T, y: T, } fn main() { let wont_work = Point { x: 5, y: 4.0 }; } 上面的代码是不能通过编译的。当把整型值 5 赋值给 x 时,就告诉了编译器这个 Point<T> 实例中的泛型 T 是整型的。接着指定 y 为 4.0,它被定义为与 x 相同类型,就会得到一个像这样的类型不匹配错误。如果想要定义一个 x 和 y 可以有不同类型且仍然是泛型的 Point 结构体,我们可以使用多个泛型类型参数。struct Point<T, U> { x: T, y: U, } fn main() { let both_integer = Point { x: 5, y: 10 }; let both_float = Point { x: 1.0, y: 4.0 }; let integer_and_float = Point { x: 5, y: 4.0 }; } 枚举定义中的泛型和结构体类似,枚举也可以在成员中存放泛型数据类型。enum Option<T> { Some(T), None, } Option<T> 是一个拥有泛型 T 的枚举,它有两个成员:Some,它存放了一个类型 T 的值不存在任何值的 None通过 Option<T> 枚举可以表达有一个可能的值的抽象概念,同时因为 Option<T> 是泛型的,无论这个可能的值是什么类型都可以使用这个抽象。枚举也可以拥有多个泛型类型。enum Result<T, E> { Ok(T), Err(E), } Result 枚举有两个泛型类型,T 和 E。Result 有两个成员:Ok,它存放一个类型 T的值Err 则存放一个类型 E 的值。这个定义使得 Result 枚举能很方便的表达任何可能成功(返回 T 类型的值)也可能失败(返回 E 类型的值)的操作。方法定义中的泛型在为结构体和枚举实现方法时,一样也可以用泛型。struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } } fn main() { let p = Point { x: 5, y: 10 }; println!("p.x = {}", p.x()); } 这里在 Point<T> 上定义了一个叫做 x 的方法来返回字段 x 中数据的引用:注意必须在 impl 后面声明 T,这样就可以在 Point<T> 上实现的方法中使用它了。在 impl 之后声明泛型 T ,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。泛型代码的性能Rust 通过在编译时进行泛型代码的 {单态化|monomorphization}来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。编译器所做的工作正好与我们创建泛型函数的步骤相反。编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。看看一个使用标准库中 Option 枚举的例子let integer = Some(5); let float = Some(5.0); 当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T> 的值并发现有两种 Option<T>:一个对应 i32 另一个对应 f64。为此,它会将泛型定义 Option<T> 展开为 Option_i32 和 Option_f64,接着将泛型定义替换为这两个具体的定义。编译器生成的单态化版本的代码看起来像这样,并包含将泛型 Option<T> 替换为编译器创建的具体定义后的用例代码:enum Option_i32 { Some(i32), None, } enum Option_f64 { Some(f64), None, } fn main() { let integer = Option_i32::Some(5); let float = Option_f64::Some(5.0); } 可以使用泛型来编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样trait:定义共享的行为trait 告诉 Rust 编译器某个特定类型拥有可能与其他类型共享的功能。可以通过 trait 以一种抽象的方式定义共享的行为。可以使用 trait bounds 指定泛型是任何拥有特定行为的类型。trait 类似于其他语言中常被称为 {接口|interfaces}的功能定义 trait一个类型的行为由其可供调用的方法构成。如果可以对不同类型调用相同的方法的话,这些类型就可以共享相同的行为了。trait 定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必需的行为的集合。创建一个多媒体聚合库用来显示可能储存在 NewsArticle 或 Tweet 实例中的数据的总结。每一个结构体都需要的行为是他们是能够被总结的,这样的话就可以调用实例的 summarize 方法来请求总结。文件名: src/lib.rspub trait Summary { fn summarize(&self) -> String; } 使用 trait 关键字来声明一个 trait,后面是 trait 的名字。在大括号中声明描述实现这个 trait 的类型所需要的行为的方法签名在上面例子中是 fn summarize(&self) -> String。在方法签名后跟分号,而不是在大括号中提供其实现。接着每一个实现这个 trait 的类型都需要提供其自定义行为的方法体,编译器也会确保任何实现 Summary trait 的类型都拥有与这个签名的定义完全一致的 summarize 方法。trait 体中可以有多个方法:一行一个方法签名且都以分号结尾。为类型实现 trait定义了 Summary trait,接着就可以在多媒体聚合库中需要拥有这个行为的类型上实现它了文件名: src/lib.rspub trait Summary { fn summarize(&self) -> String; } pub struct NewsArticle { pub headline: String, pub location: String, pub author: String, pub content: String, } impl Summary for NewsArticle { fn summarize(&self) -> String { format!("{}, by {} ({})", self.headline, self.author, self.location) } } pub struct Tweet { pub username: String, pub content: String, pub reply: bool, pub retweet: bool, } impl Summary for Tweet { fn summarize(&self) -> String { format!("{}: {}", self.username, self.content) } } 在类型上实现 trait 类似于实现与 trait 无关的方法。区别在于 impl 关键字之后,我们提供需要实现 trait 的名称,接着是 for 和需要实现 trait 的类型的名称。在 impl 块中,使用 trait 定义中的方法签名,不过不再后跟分号,而是需要在大括号中编写函数体来为特定类型实现 trait 方法所拥有的行为。一旦实现了 trait,我们就可以用与 NewsArticle 和 Tweet 实例的非 trait 方法一样的方式调用 trait 方法了:let tweet = Tweet { username: String::from("北宸南蓁"), content: String::from("Rust 学习笔记"), reply: false, retweet: false, }; println!("内容为: {}", tweet.summarize()); 实现 trait 时需要注意的一个限制是,只有当 trait 或者要实现 trait 的类型位于 crate 的本地作用域时,才能为该类型实现 trait。默认实现有时为 trait 中的某些或全部方法提供默认的行为,而不是在每个类型的每个实现中都定义自己的行为是很有用的。这样当为某个特定类型实现 trait 时,可以选择保留或重载每个方法的默认行为。pub trait Summary { fn summarize(&self) -> String { String::from("(Read more...)") } } 如果想要对 NewsArticle 实例使用这个默认实现,而不是定义一个自己的实现,则可以通过 impl Summary for NewsArticle {} 指定一个空的 impl 块。虽然我们不再直接为 NewsArticle 定义 summarize 方法了,但是我们提供了一个默认实现并且指定 NewsArticle实现 Summary trait。因此,我们仍然可以对 NewsArticle 实例调用 summarize 方法。默认实现允许调用相同 trait 中的其他方法,哪怕这些方法没有默认实现。如此,trait 可以提供很多有用的功能而只需要实现指定一小部分内容。pub trait Summary { fn summarize_author(&self) -> String; fn summarize(&self) -> String { format!("(Read more from {}...)", self.summarize_author()) } } 为了使用这个版本的 Summary,只需在实现 trait 时定义 summarize_author 即可。impl Summary for Tweet { fn summarize_author(&self) -> String { format!("@{}", self.username) } } 一旦定义了 summarize_author,我们就可以对 Tweet 结构体的实例调用 summarize 了,而 summarize 的默认实现会调用我们提供的 summarize_author 定义。trait 作为参数在上面的例子中为 NewsArticle 和 Tweet 类型实现了 Summary trait。我们可以定义一个函数 notify 来调用其参数 item 上的 summarize 方法,该参数是实现了 Summary trait 的某种类型。为此可以使用 impl Trait 语法,像这样pub fn notify(item: impl Summary) { println!("{}", item.summarize()); } 对于 item 参数,我们指定了 impl 关键字和 trait 名称,而不是具体的类型。该参数支持任何实现了指定 trait的类型。在 notify 函数体中,可以调用任何来自 Summary trait 的方法,比如 summarize。Trait Bound 语法impl Trait 语法适用于直观的例子,它实际上是一种较长形式语法的语法糖。我们称为 trait bound。pub fn notify<T: Summary>(item: T) { println!("{}", item.summarize()); } trait bound 与泛型参数声明在一起,位于尖括号中的冒号后面。impl Trait 很方便,适用于短小的例子。trait bound 则适用于更复杂的场景。例如,可以获取两个实现了 Summary 的参数。使用 impl Trait 的语法看起来像这样:pub fn notify(item1: impl Summary, item2: impl Summary) {} 这适用于 item1 和 item2 允许是不同类型的情况(只要它们都实现了 Summary)。不过如果你希望强制它们都是相同类型呢?这只有在使用 trait bound 时才有可能:pub fn notify<T: Summary>(item1: T, item2: T) { 泛型 T 被指定为 item1 和 item2 的参数限制,如此传递给参数 item1 和 item2 值的具体类型必须一致。通过 + 指定多个 trait bound如果 notify 需要显示 item 的格式化形式,同时也要使用 summarize 方法,那么 item 就需要同时实现两个不同的 trait:Display 和 Summary。这可以通过 + 语法实现:pub fn notify(item: impl Summary + Display) { + 语法也适用于泛型的 trait bound:pub fn notify<T: Summary + Display>(item: T) { 通过 where 简化 trait bound使用过多的 trait bound 也有缺点。每个泛型有其自己的 trait bound,所以有多个泛型参数的函数在名称和参数列表之间会有很长的 trait bound 信息,这使得函数签名难以阅读。为此,Rust 有另一个在函数签名之后的 where 从句中指定 trait bound 的语法。所以除了这么写:fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 { 还可以像这样使用 where 从句:fn some_function<T, U>(t: T, u: U) -> i32 where T: Display + Clone, U: Clone + Debug { 返回实现了 trait 的类型也可以在返回值中使用 impl Trait 语法,来返回实现了某个 trait 的类型fn returns_summarizable() -> impl Summary { Tweet { username: String::from("北宸南蓁"), content: String::from("Rust 学习笔记"), reply: false, retweet: false, } } 通过使用 impl Summary 作为返回值类型,我们指定了 returns_summarizable 函数返回某个实现了 Summary trait 的类型,但是不确定其具体的类型。使用 trait bounds 来修复 largest 函数在 largest 函数体中我们想要使用大于运算符(>)比较两个 T 类型的值。这个运算符被定义为标准库中 trait std::cmp::PartialOrd 的一个默认方法。所以需要在 T 的 trait bound 中指定 PartialOrd,这样 largest 函数可以用于任何可以比较大小的类型的 slice。因为 PartialOrd 位于 prelude 中所以并不需要手动将其引入作用域。将 largest 的签名修改为如下:fn largest<T: PartialOrd>(list: &[T]) -> T { 但是如果编译代码的话,会出现一些不同的错误:错误的核心是 cannot move out of type [T], a non-copy slice,对于非泛型版本的 largest 函数,我们只尝试了寻找最大的 i32 和 char。在前面章节中介绍过,像 i32 和 char 这样的类型是已知大小的并可以储存在栈上,所以他们实现了 Copy trait。当我们将 largest 函数改成使用泛型后,现在 list 参数的类型就有可能是没有实现 Copy trait 的。这意味着我们可能不能将 list[0] 的值移动到 largest 变量中,这导致了上面的错误。为了只对实现了 Copy 的类型调用这些代码,可以在 T 的 trait bounds 中增加 Copy!下面代码中展示了一个可以编译的泛型版本的 largest 函数的完整代码,只要传递给 largest 的 slice 值的类型实现了 PartialOrd 和 Copy 这两个 trait,例如 i32 和 char:fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { let mut largest = list[0]; for &item in list.iter() { if item > largest { largest = item; } } largest } 生命周期与引用有效性Rust 中的每一个引用都有其{生命周期|lifetime},也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以一些不同方式相关联的情况,所以 Rust 需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。生命周期避免了悬垂引用生命周期的主要目标是避免悬垂引用,它会导致程序引用了非预期引用的数据。存在如下代码:{ let r; { let x = 5; r = &x; } println!("r: {}", r); } 外部作用域声明了一个没有初值的变量 r,而内部作用域声明了一个初值为 5 的变量 x。在内部作用域中,我们尝试将 r 的值设置为一个 x 的引用。接着在内部作用域结束后,尝试打印出 r 的值。这段代码不能编译因为 r 引用的值在尝试使用之前就离开了作用域。变量 x 并没有 “存在的足够久”。其原因是 x 在到达内部作用域结束时就离开了作用域。不过 r 在外部作用域仍是有效的;作用域越大我们就说它 “存在的越久”。如果 Rust 允许这段代码工作,r 将会引用在 x 离开作用域时被释放的内存,这时尝试对 r 做任何操作都不能正常工作。那么 Rust 是如何决定这段代码是不被允许的呢?这得益于借用检查器。借用检查器Rust 编译器有一个{借用检查器|borrow checker},它比较作用域来确保所有的借用都是有效的。{ let r; // ---------+-- 'a // | { // | let x = 5; // -+-- 'b | r = &x; // | | } // -+ | // | println!("r: {}", r); // | } // ---------+ 这里将 r 的生命周期标记为 'a 并将 x 的生命周期标记为 'b。如你所见,内部的 'b 块要比外部的生命周期 'a 小得多。在编译时,Rust 比较这两个生命周期的大小,并发现 r 拥有生命周期 'a,不过它引用了一个拥有生命周期 'b 的对象。程序被拒绝编译,因为生命周期 'b 比生命周期 'a 要小:被引用的对象比它的引用者存在的时间更短。我们再做一个并没有产生悬垂引用且可以正确编译的例子:{ let x = 5; // ----------+-- 'b // | let r = &x; // --+-- 'a | // | | println!("r: {}", r); // | | // --+ | } // ----------+ 这里 x 拥有生命周期 'b,比 'a 要大。这就意味着 r 可以引用 x:Rust 知道 r 中的引用在 x 有效的时候也总是有效的。函数中的泛型生命周期编写一个函数,返回两个字符串 slice 中较长的那一个。这个函数获取两个字符串 slice 并返回一个字符串 slice。fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest(string1.as_str(), string2); println!("最长的字符串为{}", result); } 这个函数获取作为引用的字符串 slice,因为我们不希望 longest 函数获取参数的所有权。我们期望该函数接受 String 的 slice(参数 string1 的类型)和字符串字面量(包含于参数 string2)fn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } } 它并不能编译:相应地会出现关于有关生命周期的错误。error[E0106]: missing lifetime specifier --> src/main.rs:1:33 | 1 | fn longest(x: &str, y: &str) -> &str { | ^ expected lifetime parameter 提示文本揭示了返回值需要一个泛型生命周期参数,因为 Rust 并不知道将要返回的引用是指向 x 或 y。为了修复这个错误,我们将增加泛型生命周期参数来定义引用间的关系以便借用检查器可以进行分析。生命周期标注语法生命周期标注并不改变任何引用的生命周期的长短。与当函数签名中指定了泛型类型参数后就可以接受任何类型一样,当指定了泛型生命周期后函数也能接受任何生命周期的引用。生命周期标注描述了多个引用生命周期相互的关系,而不影响其生命周期。生命周期标注有着一个不太常见的语法:生命周期参数名称必须以撇号(')开头,其名称通常全是小写,类似于泛型其名称非常短。'a 是大多数人默认使用的名称。生命周期参数标注位于引用的 & 之后,并有一个空格来将引用类型与生命周期标注分隔开。&i32 // 引用 &'a i32 // 带有显式生命周期的引用 &'a mut i32 // 带有显式生命周期的可变引用 我们有一个没有生命周期参数的 i32 的引用,一个有叫做 'a 的生命周期参数的 i32 的引用,和一个生命周期也是 'a 的 i32 的可变引用。单个生命周期标注本身没有多少意义,因为生命周期标注告诉 Rust 多个引用的泛型生命周期参数如何相互联系的。例如如果函数有一个生命周期 'a 的 i32 的引用的参数 first。还有另一个同样是生命周期 'a 的 i32 的引用的参数 second。这两个生命周期标注意味着引用 first 和 second 必须与这泛型生命周期存在得一样久。函数签名中的生命周期标注就像泛型类型参数,泛型生命周期参数需要声明在函数名和参数列表间的尖括号中。这里我们想要告诉 Rust 关于参数中的引用和返回值之间的限制是他们都必须拥有相同的生命周期。fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } 现在函数签名表明对于某些生命周期 a,函数会获取两个参数,他们都是与生命周期 'a 存在的一样长的字符串 slice。函数会返回一个同样也与生命周期 'a 存在的一样长的字符串 slice。它的实际含义是 longest 函数返回的引用的生命周期与传入该函数的引用的生命周期的较小者一致。这就是我们告诉 Rust 需要其保证的约束条件。记住通过在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期,而是指出任何不满足这个约束条件的值都将被借用检查器拒绝。当在函数中使用生命周期标注时,这些标注出现在函数签名中,而不存在于函数体中的任何代码中。这是因为 Rust 能够分析函数中代码而不需要任何协助,不过当函数引用或被函数之外的代码引用时,让 Rust 自身分析出参数或返回值的生命周期几乎是不可能的。这些生命周期在每次函数被调用时都可能不同。这也就是为什么我们需要手动标记生命周期。当具体的引用被传递给 longest 时,被 'a 所替代的具体生命周期是 x 的作用域与 y 的作用域相重叠的那一部分。换一种说法就是泛型生命周期 'a 的具体生命周期等同于 x 和 y 的生命周期中较小的那一个。因为我们用相同的生命周期参数 'a 标注了返回的引用值,所以返回的引用值就能保证在 x 和 y 中较短的那个生命周期结束之前保持有效。深入理解生命周期指定生命周期参数的正确方式依赖函数实现的具体功能。fn longest<'a>(x: &'a str, y: &str) -> &'a str { x } 这个例子中,我们为参数 x 和返回值指定了生命周期参数 'a,不过没有为参数 y 指定,因为 y 的生命周期与参数 x 和返回值的生命周期没有任何关系。当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用 没有 指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值,它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。fn longest<'a>(x: &str, y: &str) -> &'a str { let result = String::from("really long string"); result.as_str() } 即便我们为返回值指定了生命周期参数 a,这个实现却编译失败了,因为返回值的生命周期与参数完全没有关联。出现的问题是 result 在 longest 函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个 result 的引用。无法指定生命周期参数来改变悬垂引用,而且 Rust 也不允许我们创建一个悬垂引用。在这种情况,最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。综上,生命周期语法是用于将函数的多个参数与其返回值的生命周期进行关联的。一旦他们形成了某种关联,Rust 就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。结构体定义中的生命周期标注将定义包含引用的结构体,不过这需要为结构体定义中的每一个引用添加生命周期标注。struct ImportantExcerpt<'a> { part: &'a str, } fn main() { let novel = String::from("前端.柒八九"); let first_sentence = novel.split('.') .next() .expect("没有包含'.'"); let i = ImportantExcerpt { part: first_sentence }; } 结构体有一个字段,part,它存放了一个字符串 slice,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。这个标注意味着 ImportantExcerpt 的实例不能比其 part 字段中的引用存在的更久。main 函数创建了一个 ImportantExcerpt 的实例,它存放了变量 novel 所拥有的 String 的第一个句子的引用。novel 的数据在 ImportantExcerpt 实例创建之前就存在。另外,直到 ImportantExcerpt 离开作用域之后 novel都不会离开作用域,所以 ImportantExcerpt 实例中的引用是有效的。生命周期省略(Lifetime Elision)我们已经知道了每一个引用都有一个生命周期,而且我们需要为那些使用了引用的函数或结构体指定生命周期。有一个函数,如下所示,它没有生命周期标注却能编译成功:fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] } 这个函数没有生命周期标注却能编译是由于一些历史原因:在早期版本(pre-1.0)的 Rust 中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:fn first_word<'a>(s: &'a str) -> &'a str { 在编写了很多 Rust 代码后,Rust 团队发现在特定情况下 Rust 开发者们总是重复地编写一模一样的生命周期标注。这些场景是可预测的并且遵循几个明确的模式。接着 Rust 团队就把这些模式编码进了 Rust 编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制开发者显式的增加标注。被编码进 Rust 引用分析的模式被称为{生命周期省略规则|lifetime elision rules}。省略规则并不提供完整的推断:如果 Rust 在明确遵守这些规则的前提下变量的生命周期仍然是模棱两可的话,它不会猜测剩余引用的生命周期应该是什么。在这种情况,编译器会给出一个错误,这可以通过增加对应引用之间相联系的生命周期标注来解决。函数或方法的参数的生命周期被称为 {输入生命周期|input lifetimes}而返回值的生命周期被称为 {输出生命周期|output lifetimes}。编译器采用三条规则来判断引用何时不需要明确的标注。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果编译器检查完这三条规则后仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。这些规则适用于 fn 定义,以及 impl 块。第一条规则是每一个是引用的参数都有它自己的生命周期参数。换句话说就是,有一个引用参数的函数有一个生命周期参数:fn foo<'a>(x: &'a i32),有两个引用参数的函数有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此类推。第二条规则是如果只有一个输入生命周期参数,那么它被赋予所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32。第三条规则是如果方法有多个输入生命周期参数并且其中一个参数是 &self 或 &mut self,说明是个对象的方法(method), 那么所有输出生命周期参数被赋予 self 的生命周期。第三条规则使得方法更容易读写,因为只需更少的符号。方法定义中的生命周期标注声明和使用生命周期参数的位置依赖于生命周期参数是否同结构体字段或方法参数和返回值相关。(实现方法时)结构体字段的生命周期必须总是在 impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。impl 块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期标注。impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("{}", announcement); self.part } } 这里有两个输入生命周期,所以 Rust 应用第一条生命周期省略规则并给予 &self 和 announcement 他们各自的生命周期。接着,因为其中一个参数是 &self,返回值类型被赋予了 &self 的生命周期,这样所有的生命周期都被计算出来了。静态生命周期有一种特殊的生命周期值得讨论:'static,其生命周期能够存活于整个程序期间****。所有的字符串字面量都拥有 'static 生命周期。let s: &'static str = "前端柒八九"; 这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面量都是 'static 的。总结泛型类型参数意味着代码可以适用于不同的类型。trait 和 trait bounds 保证了即使类型是泛型的,这些类型也会拥有所需要的行为。由生命周期标注所指定的引用生命周期之间的关系保证了这些灵活多变的代码不会出现悬垂引用。
0
0
0
浏览量962
前端码农

Rust学习笔记之包、Crate和模块

所有系统都有一种自毁趋势,往“熄灭”或者“圆寂”方向发展。这个趋势就叫“熵增”。为了维持系统,需要持续的输入能量,这种持续输入的能量我们就叫“负熵流”。 《向上生长》今天,我们继续Rust学习笔记的探索。我们来谈谈关于包、Crate和模块的相关知识点。如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。你能所学到的知识点Rust中包和 crate 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️模块控制作用域与私有性 推荐阅读指数 ⭐️⭐️⭐️⭐️ 路径用于引用模块树中的项 推荐阅读指数 ⭐️⭐️⭐️⭐️ use 将名称引入作用域 推荐阅读指数 ⭐️⭐️⭐️⭐️ 将模块分割进不同文件 推荐阅读指数 ⭐️⭐️⭐️好了,天不早了,干点正事哇。伴随着项目的增长,你可以通过将代码分解为多个模块和多个文件来组织代码。一个包可以包含多个二进制 crate 项和一个可选的 crate 库。伴随着包的增长,你可以将包中的部分代码提取出来,做成独立的 crate,这些 crate 则作为外部依赖项。Rust的{模块系统|the module system},包括: 包(Packages): Cargo 的一个功能,它允许你构建、测试和分享 crate。 Crates :一个模块的树形结构,它形成了库或二进制项目。 模块(Modules)和 use: 允许你控制作用域和路径的私有性。 路径(path):一个命名例如结构体、函数或模块等项的方式包和 crate包(package) 是**提供一系列功能的一个或者多个 crate。**一个包会包含有一个 Cargo.toml 文件,阐述如何去构建这些 crate。crate 是一个二进制项或者库。crate root 是一个源文件,Rust 编译器以它为起始点,并构成你的 crate 的根模块。包中所包含的内容由几条规则来确立。 一个包中至多只能包含一个{库 crate|library crate}; 包中可以包含任意多个{二进制 crate|binary crate}; 包中至少包含一个 crate,无论是库的还是二进制的。输入命令 cargo new:$ cargo new my-project Created binary (application) `my-project` package $ ls my-project Cargo.toml src $ ls my-project/src main.rs 当我们输入了这条命令,Cargo 会给我们的包创建一个 Cargo.toml 文件。查看 Cargo.toml 的内容,会发现并没有提到 src/main.rs,因为 Cargo 遵循的一个约定:src/main.rs 就是一个与包同名的{二进制 crate|binary crate} 的 crate 根。 同样的,Cargo 知道如果包目录中包含 src/lib.rs,则包带有与其同名的{库 crate|library crate},且 src/lib.rs 是 crate 根。crate 根文件将由 Cargo 传递给 rustc 来实际构建库或者二进制项目。如果一个包同时含有 src/main.rs 和 src/lib.rs,则它有两个 crate:一个库和一个二进制项,且名字都与包相同。通过将文件放在 src/bin 目录下,一个包可以拥有多个二进制 crate:每个 src/bin 下的文件都会被编译成一个独立的{二进制 crate|binary crate}。一个 crate 会将一个作用域内的相关功能分组到一起,使得该功能可以很方便地在多个项目之间共享。定义模块来控制作用域与私有性模块 让我们可以将一个 crate 中的代码进行分组,以提高可读性与重用性。模块还可以控制项的 私有性项是可以被外部代码使用的(public)还是作为一个内部实现的内容,不能被外部代码使用(private)。通过执行 cargo new --lib restaurant,来创建一个新的名为 restaurant的库。在 restaurant/src/lib.rs 中,来定义一些模块和函数。fn main() { mod front_of_house { mod hosting { fn add_to_waitlist() {} fn seat_at_table() {} } mod serving { fn take_order() {} fn server_order() {} fn take_payment() {} } } } 用关键字 mod 定义一个模块,指定模块的名字,并用大括号包围模块的主体。我们可以在模块中包含其他模块,就像本示例中的 hosting 和 serving 模块。模块中也可以包含其他项,比如结构体、枚举、常量、trait。通过使用模块,我们可以把相关的定义组织起来,并通过模块命名来解释为什么它们之间有相关性。使用这部分代码的开发者可以更方便的循着这种分组找到自己需要的定义,而不需要通览所有。编写这部分代码的开发者通过分组知道该把新功能放在哪里以便继续让程序保持组织性。之前我们提到,src/main.rs 和 src/lib.rs 被称为 crate 根。如此称呼的原因是,这两个文件中任意一个的内容会构成名为 crate 的模块,且该模块位于 crate 的被称为 {模块树 的模块结构的根部|at the root of the crate’s module structure}。上面的代码所对应的模块树如下所示。crate └── front_of_house ├── hosting │ ├── add_to_waitlist │ └── seat_at_table └── serving ├── take_order ├── serve_order └── take_payment 这个树展示了模块间是如何相互嵌套的。这个树还展示了一些模块互为兄弟 ,即它们被定义在同一模块内。路径用于引用模块树中的项在 Rust 使用路径的方式在模块树中找到一个项的位置,就像在文件系统使用路径一样。如果我们想要调用一个函数,我们需要知道它的路径。路径有两种形式: {绝对路径|absolute path}从 crate 根部开始,以 crate 名或者字面量 crate 开头。 {相对路径|relative path}从当前模块开始,以 self、super 或当前模块的标识符开头。绝对路径和相对路径都后跟一个或多个由双冒号(::)分割的标识符。在 crate 根部定义了一个新函数 eat_at_restaurant,并在其中展示调用 add_to_waitlist 函数的两种方法。eat_at_restaurant 函数是我们 crate 库的一个公共 API,所以我们使用 pub 关键字来标记它。mod front_of_house { mod hosting { fn add_to_waitlist() {} } } pub fn eat_at_restaurant() { // 绝对路径 crate::front_of_house::hosting::add_to_waitlist(); // 相对路径 front_of_house::hosting::add_to_waitlist(); } 第一种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist 函数,使用的是绝对路径。add_to_waitlist 函数与 eat_at_restaurant 被定义在同一 crate 中,这意味着我们可以使用 crate 关键字为起始的绝对路径。在 crate 后面,我们持续地嵌入模块,直到我们找到 add_to_waitlist。你可以想象出一个相同结构的文件系统,我们通过指定路径 /front_of_house/hosting/add_to_waitlist 来执行 add_to_waitlist 程序。我们使用 crate 从 crate 根部开始就类似于在 shell 中使用 / 从文件系统根开始。第二种方式,我们在 eat_at_restaurant 中调用 add_to_waitlist,使用的是相对路径。这个路径以 front_of_house 为起始,这个模块在模块树中,与 eat_at_restaurant 定义在同一层级。与之等价的文件系统路径就是 front_of_house/hosting/add_to_waitlist。以名称为起始,意味着该路径是相对路径。模块不仅对于你组织代码很有用。他们还定义了 Rust 的 {私有性边界|privacy boundary}:这条界线不允许外部代码了解、调用和依赖被封装的实现细节。所以,如果你希望创建一个私有函数或结构体,你可以将其放入模块。Rust 中默认所有项(函数、方法、结构体、枚举、模块和常量)都是私有的。 父模块中的项不能使用子模块中的私有项,但是子模块中的项可以使用他们父模块中的项。这是因为子模块封装并隐藏了他们的实现详情,但是子模块可以看到他们定义的上下文。使用 pub 关键字暴露路径想让父模块中的 eat_at_restaurant 函数可以访问子模块中的 add_to_waitlist 函数,因此我们使用 pub 关键字来标记 hosting 模块mod front_of_house { pub mod hosting { fn add_to_waitlist() {} } } pub fn eat_at_restaurant() { // 绝对路径 crate::front_of_house::hosting::add_to_waitlist(); // 相对路径 front_of_house::hosting::add_to_waitlist(); } cargo build的时候,代码编译仍然有错误。在 mod hosting 前添加了 pub 关键字,使其变成公有的。伴随着这种变化,如果我们可以访问 front_of_house,那我们也可以访问 hosting。但是 hosting 的 {内容|contents}仍然是私有的;这表明使模块公有并不使其内容也是公有的。模块上的 pub 关键字只允许其父模块引用它。继续将 pub 关键字放置在 add_to_waitlist 函数的定义之前,使其变成公有。mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } pub fn eat_at_restaurant() { // 绝对路径 crate::front_of_house::hosting::add_to_waitlist(); // 相对路径 front_of_house::hosting::add_to_waitlist(); } 使用 super 起始的相对路径还可以使用 super 开头来构建从父模块开始的相对路径。这么做类似于文件系统中以 .. 开头的语法。如下模拟了厨师更正了一个错误订单,并亲自将其提供给客户的情况。fix_incorrect_order 函数通过指定的 super 起始的 serve_order 路径,来调用 serve_order 函数:fn serve_order() {} mod back_of_house { fn fix_incorrect_order() { cook_order(); super::serve_order(); } fn cook_order() {} } fix_incorrect_order 函数在 back_of_house 模块中,所以我们可以使用 super 进入 back_of_house 父模块,也就是本例中的 crate 根。在这里,我们可以找到 serve_order。成功!创建公有的结构体和枚举还可以使用 pub 来设计公有的结构体和枚举,不过有一些额外的细节需要注意。如果我们在一个结构体定义的前面使用了 pub ,这个结构体会变成公有的,但是这个结构体的字段仍然是私有的。我们可以根据情况决定每个字段是否公有。定义了一个公有结构体 back_of_house:Breakfast,其中有一个公有字段 toast 和私有字段 seasonal_fruitmod back_of_house { pub struct Breakfast { pub toast: String, seasonal_fruit: String, } impl Breakfast { // 定义关联函数 pub fn summer(toast: &str) -> Breakfast { Breakfast { toast: String::from(toast), seasonal_fruit: String::from("桃子"), } } } } pub fn eat_at_restaurant() { let mut meal = back_of_house::Breakfast::summer("Rye"); meal.toast = String::from("Wheat"); println!("我喜欢吃{} ", meal.toast); } 因为 back_of_house::Breakfast 结构体的 toast 字段是公有的,所以我们可以在 eat_at_restaurant 中使用点号来随意的读写 toast 字段。因为 back_of_house::Breakfast 具有私有字段,所以这个结构体需要提供一个公共的关联函数来构造 Breakfast 的实例。如果 Breakfast 没有这样的函数,我们将无法在 eat_at_restaurant 中创建 Breakfast 实例,因为我们不能在 eat_at_restaurant 中设置私有字段 seasonal_fruit 的值。如果我们将枚举设为公有,则它的所有成员都将变为公有。我们只需要在 enum 关键字前面加上 pub。mod back_of_house { pub enum Appetizer { Soup, Salad, } } pub fn eat_at_restaurant() { let order1 = back_of_house::Appetizer::Soup; let order2 = back_of_house::Appetizer::Salad; } 使用 use 关键字将名称引入作用域我们可以使用 use 关键字将路径一次性引入作用域,然后调用该路径中的项,就如同它们是本地项一样。将 crate::front_of_house::hosting 模块引入了 eat_at_restaurant 函数的作用域,而我们只需要指定 hosting::add_to_waitlist 即可在 eat_at_restaurant 中调用 add_to_waitlist 函数。mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } use crate::front_of_house::hosting; pub fn eat_at_restaurant() { hosting::add_to_waitlist(); hosting::add_to_waitlist(); hosting::add_to_waitlist(); } fn main() {} 在作用域中增加 use 和路径类似于在文件系统中创建软连接({符号连接|symbolic link})。通过在 crate 根增加 use crate::front_of_house::hosting,现在 hosting 在作用域中就是有效的名称了,如同 hosting 模块被定义于 crate 根一样。通过 use 引入作用域的路径也会检查私有性,同其它路径一样。还可以使用 use 和相对路径来将一个项引入作用域。mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } use front_of_house::hosting; pub fn eat_at_restaurant() { hosting::add_to_waitlist(); hosting::add_to_waitlist(); hosting::add_to_waitlist(); } fn main() {} 创建惯用的 use 路径要想使用 use 将函数的父模块引入作用域,我们必须在调用函数时指定父模块,这样可以清晰地表明函数不是在本地定义的,同时使完整路径的重复度最小化。另一方面,使用 use 引入结构体、枚举和其他项时,习惯是指定它们的完整路径。use std::collections::HashMap; fn main() { let mut map = HashMap::new(); map.insert(1, 2); } 这个习惯用法有一个例外,那就是我们想使用 use 语句将两个具有相同名称的项带入作用域,因为 Rust 不允许这样做。fn main() { use std::fmt; use std::io; fn function1() -> fmt::Result { // --snip-- Ok(()) } fn function2() -> io::Result<()> { // --snip-- Ok(()) } } 使用父模块可以区分这两个 Result 类型。如果我们是指定 use std::fmt::Result 和 use std::io::Result,我们将在同一作用域拥有了两个 Result 类型,当我们使用 Result 时,Rust 则不知道我们要用的是哪个。使用 as 关键字提供新的名称使用 use 将两个同名类型引入同一作用域这个问题还有另一个解决办法:在这个类型的路径后面,我们使用 as 指定一个新的本地名称或者别名。fn main() { use std::fmt::Result; use std::io::Result as IoResult; fn function1() -> Result { // --snip-- Ok(()) } fn function2() -> IoResult<()> { // --snip-- Ok(()) } } 在第二个 use 语句中,我们选择 IoResult 作为 std::io::Result 的新名称,它与从 std::fmt 引入作用域的 Result 并不冲突。使用 pub use 重导出名称当使用 use 关键字将名称导入作用域时,在新作用域中可用的名称是私有的。如果为了让调用你编写的代码的代码能够像在自己的作用域内引用这些类型,可以结合 pub 和 use。这个技术被称为 {重导出|re-exporting},因为这样做将项引入作用域并同时使其可供其他代码引入自己的作用域。mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } pub use crate::front_of_house::hosting; pub fn eat_at_restaurant() { hosting::add_to_waitlist(); hosting::add_to_waitlist(); hosting::add_to_waitlist(); } fn main() {} 通过 pub use,现在可以通过新路径 hosting::add_to_waitlist 来调用 add_to_waitlist 函数。如果没有指定 pub use,eat_at_restaurant 函数可以在其作用域中调用 hosting::add_to_waitlist,但外部代码则不允许使用这个新路径。使用外部包假设项目使用了一个外部包,rand,来生成随机数。为了在项目中使用 rand,在 Cargo.toml 中加入了如下行:文件名: Cargo.toml[dependencies] rand = "0.8.3" 在 Cargo.toml 中加入 rand 依赖告诉了 Cargo 要从 crates.io 下载 rand 和其依赖,并使其可在项目代码中使用。接着,为了将 rand 定义引入项目包的作用域,我们加入一行 use 起始的包名,它以 rand 包名开头并列出了需要引入作用域的项。use rand::Rng; fn main() { let secret_number = rand::thread_rng() .gen_range(1..101); } crates.io 上有很多 Rust 社区成员发布的包,将其引入你自己的项目都需要一道相同的步骤:在 Cargo.toml 列出它们并通过 use 将其中定义的项引入项目包的作用域中。标准库(std)对于你的包来说也是外部 crate。因为标准库随 Rust 语言一同分发,无需修改 Cargo.toml 来引入 std,不过需要通过 use 将标准库中定义的项引入项目包的作用域中来引用它们,比如我们使用的 HashMap:fn main() { use std::collections::HashMap; } 嵌套路径来消除大量的 use 行当需要引入很多定义于相同包或相同模块的项时,为每一项单独列出一行会占用源码很大的空间。fn main() { use std::cmp::Ordering; use std::io; } 我们可以使用嵌套路径将相同的项在一行中引入作用域。这么做需要指定路径的相同部分,接着是两个冒号,接着是大括号中的各自不同的路径部分。fn main() { use std::{cmp::Ordering, io}; // ---snip--- } 通过 glob 运算符将所有的公有定义引入作用域如果希望将一个路径下 所有公有项引入作用域,可以指定路径后跟 *:fn main() { use std::collections::*; } 这个 use 语句将 std::collections 中定义的所有公有项引入当前作用域。将模块分割进不同文件当模块变得更大时,你可能想要将它们的定义移动到单独的文件中,从而使代码更容易阅读。将 front_of_house 模块移动到属于它自己的文件 src/front_of_house.rs 中,通过改变 crate 根文件。在这个例子中,crate 根文件是 src/lib.rs,这也同样适用于以 src/main.rs 为 crate 根文件的二进制 crate 项。文件名: src/lib.rsmod front_of_house; pub use crate::front_of_house::hosting; pub fn eat_at_restaurant() { hosting::add_to_waitlist(); hosting::add_to_waitlist(); hosting::add_to_waitlist(); } 文件名: src/front_of_house.rspub mod hosting { pub fn add_to_waitlist() {} } 在 mod front_of_house 后使用分号,而不是代码块,这将告诉 Rust 在另一个与模块同名的文件中加载模块的内容。
0
0
0
浏览量237
前端码农

从0到1,带你搭建Vite+Vue3+Unocss+Pinia+Naive UI后台(一) 前置篇

前言今年年初开源了一个基于 Vue3+Vite+Pinia+Naive UI 的轻量级后台管理模板,没想到得到很多朋友的青睐和关注,在Vite+Vue3+NaiveUI+Pinia搭建一套优雅的后台管理模板,真香 - 掘金 (juejin.cn) 这篇文章中介绍了这套模板的特性和集成的一些功能,后面也陆陆续续加了挺多东西的,现在已经比较完善了。有很多朋友在github、邮箱和微信上表示很好用,并且帮助到了他们,这对于没有多少开源经验的我来说无疑是极大的激励,更促使我把这个开源项目做到更好。也有朋友表达了疑惑,比如“都2202年了,为什么还不用ts?”,针对这一点,我觉得有必要解释一下,这个项目是定位轻量级的中小型项目或者个人项目,特点就是简洁、轻量,当然这跟ts也并不相悖,但是使用js对于中小型项目来说无疑会有更高的效率,而且也能更好的照顾不太会ts的朋友。不能为了用ts而用ts,ts解决的无非是规范和代码提示的问题,配置好vscode,代码提示已经很全面了,各种跳转定义和自动引入也很顺畅;至于规范,我觉得ts只能尽可能的保证代码的最低质量,而且还要花费好多心力和多写很多代码,大项目无可厚非,中小项目的话意义不大。(非引战,纯粹一家之言,狗头保命)。废话不再多说,接下来进入主题前置知识准备技术栈Js、Css、Html无需多说Vue2 / Vue3,建议还没有掌握的可以直接上Vue3, 官方文档 | Vue.js (vuejs.org)NodeJs、Npm、Git,会简单使用指令就行环境NodeJs, 14+版本以上,建议上16+Npm,一般是跟NodeJs绑定安装的,略过Pnpm,强烈建议使用,用过的都说香,操作手册 | pnpm.io工具使用VsCode就够了,前端开发第一编辑器(非引战,但欢迎在评论区讨论)推荐几款插件,烂大街的就不推荐了any-rule: 你要的"正则"都在这!Better Comments: 更友好的代码注释DotENV: env文件高亮One Monokai Theme:换过很多主题还是觉得这个主题看着最舒服Volar: Vue3必备,不多说官方文档传送门Vite: Home | Vite 官方中文文档 (vitejs.dev)Vue3: 介绍 | Vue.js (vuejs.org)Pinia: Home | Pinia (vuejs.org)Naive UI: Naive UI: 一个 Vue 3 组件库搭建 Vite 起始项目注意:这里全部使用pnpm操作,没有安装pnpm的需先按照手册安装安装(超简单的,傻瓜式操作)npm install -g pnpm # 如安装了可忽略 pnpm create vite安装依赖并启动,一般网络无问题且Node版本14+以上是可以顺利安装和启动的,如安装失败可删除nodecd vue-naive-admin pnpm i pnpm run dev看到这个页面就启动成功了这是初始化文件结构,接下来创建第一个git提交git init git add -A git commit -m "first commit"这一篇先到此为止,如有错误之处请在评论区提醒指正,一起讨论学习~
0
0
0
浏览量2029
前端码农

Rust学习笔记之闭包和迭代器

{行动是绝望的毒药|Action is the antidote to despair} -- 美国音乐家 琼·贝今天,我们继续Rust学习笔记的探索。我们来谈谈关于Rust学习笔记之闭包和迭代器的相关知识点。如果,想了解该系列的文章,可以参考我们已经发布的文章。你能所学到的知识点函数式编程 推荐阅读指数 ⭐️⭐️⭐️⭐️ 闭包:可以捕获环境的匿名函数 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️ 使用迭代器处理元素序列 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️好了,天不早了,干点正事哇。函数式编程函数式编程是一种编程范式,它将计算视为数学函数的求值过程,强调函数的纯洁性和不可变性。函数式编程语言通常支持高阶函数、闭包、惰性求值等特性,可以提高代码的可读性、可维护性和可扩展性。常见的函数式编程语言包括:Haskell、Lisp、Scheme、ML、Erlang、Clojure、Scala、F#等。JS中函数式编程的体现在JS中,高阶函数、闭包、惰性求值等特性都是函数式编程的重要组成部分。高阶函数高阶函数是指接受函数作为参数或返回函数的函数。它可以将函数作为一等公民来处理,实现代码的抽象和复用。例如,下面的代码定义了一个高阶函数map,它接受一个函数和一个数组作为参数,返回一个新的数组,其中每个元素都是原数组中对应元素经过函数处理后的结果。const map = (f, arr) => arr.map(f); const square = x => x * x; const numbers = [1, 2, 3, 4, 5]; console.log(map(square, numbers)); // [1, 4, 9, 16, 25] 闭包闭包是指函数和其相关的引用环境组合而成的实体。它可以实现数据的封装和隐藏,避免全局变量的污染和冲突。例如,下面的代码定义了一个闭包,它返回一个函数,每次调用时都会累加传入的参数,并返回累加后的结果。const add = (() => { let sum = 0; return x => { sum += x; return sum; }; })(); console.log(add(1)); // 1 console.log(add(2)); // 3 console.log(add(3)); // 6 惰性求值惰性求值是指在需要时才进行计算,避免不必要的计算和资源浪费。它可以实现懒加载和缓存等优化策略,提高代码的性能和效率。例如,下面的代码定义了一个惰性求值函数,它接受一个函数作为参数,返回一个新的函数,每次调用时都会检查是否已经计算过结果,如果没有则进行计算并缓存结果,提高代码的性能和效率。const memoize = fn => { const cache = new Map(); return (...args) => { const key = JSON.stringify(args); const val = cache.get(key); if (val) { return val; } const res = fn(...args); cache.set(key, res); return res; }; }; const factorial = memoize(n => { if (n === 0) { return 1; } return n * factorial(n - 1); }); console.log(factorial(5)); // 120 console.log(factorial(5)); // 120 (返回缓存数据) 虽然Rust 的设计灵感来源于很多现存的语言和技术。但是其中一个显著的影响就是 {函数式编程|functional programming}。下面,我们就针对一些特性来展开说明闭包:可以捕获环境的匿名函数Rust 的 闭包(closures)是可以保存进变量或作为参数传递给其他函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。 不同于函数,闭包允许捕获调用者作用域中的值。使用闭包创建行为的抽象假定存在如下场景:我们在一个通过 app 生成自定义健身计划的初创企业工作。其后端使用 Rust 编写,而生成健身计划的算法需要考虑很多不同的因素,比如用户的年龄、身体质量指数、用户喜好、最近的健身活动和用户指定的强度系数。将通过调用 simulated_expensive_calculation 函数来模拟调用假定的算法。use std::thread; use std::time::Duration; fn simulated_expensive_calculation(intensity: u32) -> u32 { println!("计算中..."); thread::sleep(Duration::from_secs(2)); intensity } main 函数中将会包含健身 app 中的重要部分。所需的输入有这些:一个来自用户的 intensity 数字,请求健身计划时指定,它代表用户喜好低强度还是高强度健身。一个随机数,其会在健身计划中生成变化。fn main() { let simulated_user_specified_value = 10; let simulated_random_number = 7; generate_workout( simulated_user_specified_value, simulated_random_number ); } generate_workout 函数包含我们最关心的 app 业务逻辑。fn generate_workout(intensity: u32, random_number: u32) { if intensity < 25 { println!( "今天做 {} 俯卧撑!", simulated_expensive_calculation(intensity) ); println!( "接下来做 {} 仰卧起坐!", simulated_expensive_calculation(intensity) ); } else { if random_number == 3 { println!("休息一会"); } else { println!( "今天运动了 {} 分钟!", simulated_expensive_calculation(intensity) ); } } } 代码有多处调用了慢计算函数 simulated_expensive_calculation。第一个 if 块调用了 simulated_expensive_calculation 两次, else 中的 if 没有调用它,而第二个 else 中的代码调用了它一次。为了简化更新步骤,我们将重构代码来让它只调用 simulated_expensive_calculation 一次。同时还希望去掉目前多余的连续两次函数调用,并不希望在计算过程中增加任何其他此函数的调用。也就是说,我们不希望在完全无需其结果的情况调用函数,不过仍然希望只调用函数一次。使用函数重构尝试的是将重复的 simulated_expensive_calculation 函数调用提取到一个变量中。fn generate_workout(intensity: u32, random_number: u32) { let expensive_result = simulated_expensive_calculation(intensity); if intensity < 25 { println!( "今天做 {} 俯卧撑!", expensive_result ); println!( "接下来做 {} 仰卧起坐!", expensive_result ); } else { if random_number == 3 { println!("休息一会"); } else { println!( "今天运动了 {} 分钟!", expensive_result ); } } } 将 simulated_expensive_calculation 调用提取到一个位置,并将结果储存在变量 expensive_result 中。这个修改统一了 simulated_expensive_calculation 调用并解决了第一个 if 块中不必要的两次调用函数的问题。不幸的是,现在所有的情况下都需要调用函数并等待结果,包括那个完全不需要这一结果的内部 if 块。希望能够在程序的一个位置指定某些代码,并只在程序的某处实际需要结果的时候执行这些代码。这正是闭包的用武之地!重构使用闭包储存代码不同于总是在 if 块之前调用 simulated_expensive_calculation 函数并储存其结果,我们可以定义一个闭包并将其储存在变量中。实际上可以选择将整个 simulated_expensive_calculation 函数体移动到这里引入的闭包中:let expensive_closure = |num| { println!("计算中..."); thread::sleep(Duration::from_secs(2)); num }; 闭包定义是 expensive_closure 赋值的 = 之后的部分。闭包的定义以一对竖线(|)开始,在竖线中指定闭包的参数。这个闭包有一个参数 num;如果有多于一个参数,可以使用逗号分隔,比如 |param1, param2|。参数之后是存放闭包体的大括号 —— 如果闭包体只有一行则大括号是可以省略的。大括号之后闭包的结尾,需要用于 let 语句的分号。因为闭包体的最后一行没有分号,所以闭包体(num)最后一行的返回值作为调用闭包时的返回值 。这个 let 语句意味着 expensive_closure 包含一个匿名函数的定义,不是调用匿名函数的返回值。使用闭包的原因是我们需要在一个位置定义代码,储存代码,并在之后的位置实际调用它;期望调用的代码现在储存在 expensive_closure 中。定义了闭包之后,可以改变 if 块中的代码来调用闭包以执行代码并获取结果值。调用闭包类似于调用函数;指定存放闭包定义的变量名并后跟包含期望使用的参数的括号。fn generate_workout(intensity: u32, random_number: u32) { let expensive_closure = |num| { println!("calculating slowly..."); thread::sleep(Duration::from_secs(2)); num }; if intensity < 25 { println!( "今天做 {} 俯卧撑!", expensive_closure(intensity) ); println!( "接下来做 {} 仰卧起坐!", expensive_closure(intensity) ); } else { if random_number == 3 { println!("休息一会"); } else { println!( "今天运动了 {} 分钟!", expensive_closure(intensity) ); } } } 现在耗时的计算只在一个地方被调用,并只会在需要结果的时候执行该代码。闭包类型推断和标注闭包不要求像 fn 函数那样在参数和返回值上注明类型。函数中需要类型标注是因为他们是暴露给用户的显式接口的一部分。严格的定义这些接口对于保证所有人都认同函数使用和返回值的类型来说是很重要的。但是闭包并不用于这样暴露在外的接口:他们储存在变量中并被使用,不用命名他们或暴露给库的用户调用。类似于变量,可以选择增加类型标注。let expensive_closure = |num: u32| -> u32 { println!("计算中..."); thread::sleep(Duration::from_secs(2)); num }; 有了类型标注闭包的语法就更类似函数了。如下是一个对其参数加一的函数的定义与拥有相同行为闭包语法的纵向对比fn add_one_v1 (x: u32) -> u32 { x + 1 } let add_one_v2 = |x: u32| -> u32 { x + 1 }; let add_one_v3 = |x| { x + 1 }; let add_one_v4 = |x| x + 1 ; 第一行展示了一个函数定义,而第二行展示了一个完整标注的闭包定义。第三行闭包定义中省略了类型标注,而第四行去掉了可选的大括号,因为闭包体只有一行。这些都是有效的闭包定义,并在调用时产生相同的行为。闭包定义会为每个参数和返回值推断一个具体类型尝试调用闭包两次,第一次使用 String 类型作为参数而第二次使用 u32,则会得到一个错误let example_closure = |x| x; let s = example_closure(String::from("hello")); let n = example_closure(5); 尝试调用一个被推断为两个不同类型的闭包。第一次使用 String 值调用 example_closure 时,编译器推断 x 和此闭包返回值的类型为 String。接着这些类型被锁定进闭包 example_closure 中,如果尝试对同一闭包使用不同类型则会得到类型错误。使用带有泛型和 Fn trait 的闭包创建一个存放闭包和调用闭包结果的结构体。该结构体只会在需要结果时执行闭包,并会缓存结果值,这样余下的代码就不必再负责保存结果并可以复用该值。你可能见过这种模式被称 memoization 或 lazy evaluation (惰性求值)。为了让结构体存放闭包,我们需要指定闭包的类型,因为结构体定义需要知道其每一个字段的类型。每一个闭包实例有其自己独有的匿名类型:也就是说,即便两个闭包有着相同的签名,他们的类型仍然可以被认为是不同。Fn 系列 trait 由标准库提供。所有的闭包都实现了 trait Fn、FnMut 或 FnOnce 中的一个。为了满足 Fn trait bound 我们增加了代表闭包所必须的参数和返回值类型的类型。在例子中,闭包有一个 u32 的参数并返回一个 u32,这样所指定的 trait bound 就是 Fn(u32) -> u32。struct Cacher<T> where T: Fn(u32) -> u32 { calculation: T, value: Option<u32>, } 结构体 Cacher 有一个泛型 T 的字段 calculation。T 的 trait bound 指定了 T 是一个使用 Fn 的闭包。任何我们希望储存到 Cacher 实例的 calculation 字段的闭包必须有一个 u32 参数(由 Fn 之后的括号的内容指定)并必须返回一个 u32(由 -> 之后的内容)。字段 value 是 Option<u32> 类型的。在执行闭包之前,value 将是 None。如果使用 Cacher 的代码请求闭包的结果,这时会执行闭包并将结果储存在 value 字段的 Some 成员中。接着如果代码再次请求闭包的结果,这时不再执行闭包,而是会返回存放在 Some 成员中的结果。impl<T> Cacher<T> where T: Fn(u32) -> u32 { fn new(calculation: T) -> Cacher<T> { Cacher { calculation, value: None, } } fn value(&mut self, arg: u32) -> u32 { match self.value { Some(v) => v, None => { let v = (self.calculation)(arg); self.value = Some(v); v }, } } } Cacher::new 函数获取一个泛型参数 T,它定义于 impl 块上下文中并与 Cacher 结构体有着相同的 trait bound。Cacher::new 返回一个在 calculation 字段中存放了指定闭包和在 value 字段中存放了 None 值的 Cacher 实例,因为我们还未执行闭包。当调用代码需要闭包的执行结果时,不同于直接调用闭包,它会调用 value 方法。这个方法会检查 self.value 是否已经有了一个 Some 的结果值;如果有,它返回 Some 中的值并不会再次执行闭包。如果 self.value 是 None,则会调用 self.calculation 中储存的闭包,将结果保存到 self.value 以便将来使用,并同时返回结果值。fn generate_workout(intensity: u32, random_number: u32) { let mut expensive_result = Cacher::new(|num| { println!("calculating slowly..."); thread::sleep(Duration::from_secs(2)); num }); if intensity < 25 { println!( "今天做 {} 俯卧撑!", expensive_result.value(intensity) ); println!( "接下来做 {} 仰卧起坐!", expensive_result.value(intensity) ); } else { if random_number == 3 { println!("休息一下"); } else { println!( "今天运动了 {} 分钟!", expensive_result.value(intensity) ); } } } 不同于直接将闭包保存进一个变量,我们保存一个新的 Cacher 实例来存放闭包。接着,在每一个需要结果的地方,调用 Cacher 实例的 value 方法。可以调用 value 方法任意多次,或者一次也不调用,而慢计算最多只会运行一次。闭包会捕获其环境闭包还有另一个函数所没有的功能:他们可以捕获其环境并访问其被定义的作用域的变量。有一个储存在 equal_to_x 变量中闭包的例子,它使用了闭包环境中的变量 x:fn main() { let x = 4; let equal_to_x = |z| z == x; let y = 4; assert!(equal_to_x(y)); } 即便 x 并不是 equal_to_x 的一个参数,equal_to_x 闭包也被允许使用变量 x,因为它与 equal_to_x 定义于相同的作用域。函数则不能做到同样的事,它并不能编译。fn main() { let x = 4; fn equal_to_x(z: i32) -> bool { z == x } let y = 4; assert!(equal_to_x(y)); } 当闭包从环境中捕获一个值,闭包会在闭包体中储存这个值以供使用。这会使用内存并产生额外的开销闭包可以通过三种方式捕获其环境,他们直接对应函数的三种获取参数的方式:获取所有权可变借用不可变借用这三种捕获值的方式被编码为如下三个 Fn trait:FnOnce 消费从周围作用域捕获的变量,闭包周围的作用域被称为其 {环境|environment}。为了消费捕获到的变量,闭包必须获取其所有权并在定义闭包时将其移动进闭包。其名称的 Once 部分代表了闭包不能多次获取相同变量的所有权的事实,所以它只能被调用一次。FnMut 获取可变的借用值所以可以改变其环境Fn 从其环境获取不可变的借用值当创建一个闭包时,Rust 根据其如何使用环境中变量来推断我们希望如何引用环境。由于所有闭包都可以被调用至少一次,所以所有闭包都实现了 FnOnce 。那些并没有移动被捕获变量的所有权到闭包内的闭包也实现了 FnMut ,而不需要对被捕获的变量进行可变访问的闭包则也实现了 Fn 。使用迭代器处理元素序列{迭代器|iterator}是一种设计模式,它提供了一种遍历集合元素的统一接口,无需暴露集合的内部结构。迭代器可以按照不同的顺序遍历集合元素,也可以实现惰性求值和流式处理等特性,提高代码的可读性、可维护性和可扩展性。JS中的迭代器在JS中,迭代器是通过Symbol.iterator接口实现的。它可以被for...of循环、扩展运算符、解构赋值等语法所使用,也可以被自定义的迭代器函数所使用。下面的代码定义了一个迭代器函数,它接受一个数组作为参数,返回一个迭代器对象,可以按照顺序遍历数组元素。const createIterator = arr => { let i = 0; return { next: () => { if (i < arr.length) { return { value: arr[i++], done: false }; } else { return { value: undefined, done: true }; } } }; }; const numbers = [1, 2, 3, 4, 5]; const iterator = createIterator(numbers); console.log(iterator.next()); // { value: 1, done: false } console.log(iterator.next()); // { value: 2, done: false } console.log(iterator.next()); // { value: 3, done: false } console.log(iterator.next()); // { value: 4, done: false } console.log(iterator.next()); // { value: 5, done: false } console.log(iterator.next()); // { value: undefined, done: true } 在 Rust 中,迭代器是 {惰性的|lazy},这意味着在调用方法使用迭代器之前它都不会有效果。通过调用定义于 Vec 上的 iter 方法在一个 vector v1 上创建了一个迭代器。这段代码本身没有任何用处:let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); 迭代器被储存在 v1_iter 变量中,而这时没有进行迭代。一旦 for 循环开始使用 v1_iter,接着迭代器中的每一个元素被用于循环的一次迭代,这会打印出其每一个值:let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); for val in v1_iter { println!("值为: {}", val); } 在标准库中没有提供迭代器的语言中,我们可能会使用一个从 0 开始的索引变量,使用这个变量索引 vector 中的值,并循环增加其值直到达到 vector 的元素数量。迭代器为我们处理了所有这些逻辑,这减少了重复代码并消除了潜在的混乱。另外,迭代器的实现方式提供了对多种不同的序列使用相同逻辑的灵活性。Iterator trait 和 next 方法迭代器都实现了一个叫做 Iterator 的定义于标准库的 trait。pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } 这段代码表明实现 Iterator trait 要求同时定义一个 Item 类型,这个 Item 类型被用作 next 方法的返回值类型。换句话说,Item 类型将是迭代器返回元素的类型。next 是 Iterator 实现者被要求定义的唯一方法。next 一次返回迭代器中的一个项,封装在 Some 中,当迭代器结束时,它返回 None。可以直接调用迭代器的 next 方法。fn iterator_demonstration() { let v1 = vec![1, 2, 3]; let mut v1_iter = v1.iter(); assert_eq!(v1_iter.next(), Some(&1)); assert_eq!(v1_iter.next(), Some(&2)); assert_eq!(v1_iter.next(), Some(&3)); assert_eq!(v1_iter.next(), None); } v1_iter 需要是可变的:在迭代器上调用 next 方法改变了迭代器中用来记录序列位置的状态。换句话说,代码 {消费|consume}了,或使用了迭代器。每一个 next 调用都会从迭代器中消费一个项。使用 for 循环时无需使 v1_iter 可变因为 for 循环会获取 v1_iter 的所有权并在后台使 v1_iter 可变。消费迭代器的方法Iterator trait 有一系列不同的由标准库提供默认实现的方法;你可以在 Iterator trait 的标准库 API 文档中找到所有这些方法。一些方法在其定义中调用了 next 方法,这也就是为什么在实现 Iterator trait 时要求实现 next 方法的原因。这些调用 next 方法的方法被称为 {消费适配器|consuming adaptors},因为调用他们会消耗迭代器。一个消费适配器的例子是 sum 方法。这个方法获取迭代器的所有权并反复调用 next 来遍历迭代器,因而会消费迭代器。当其遍历每一个项时,它将每一个项加总到一个总和并在迭代完成时返回总和。fn iterator_sum() { let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); let total: i32 = v1_iter.sum(); assert_eq!(total, 6); } 调用 sum 之后不再允许使用 v1_iter 因为调用 sum 时它会获取迭代器的所有权。产生其他迭代器的方法Iterator trait 中定义了另一类方法,被称为 {迭代器适配器|iterator adaptors},他们允许我们将当前迭代器变为不同类型的迭代器。可以链式调用多个迭代器适配器。不过因为所有的迭代器都是惰性的,必须调用一个消费适配器方法以便获取迭代器适配器调用的结果。如下展示了一个调用迭代器适配器方法 map 的例子,该 map 方法使用闭包来调用每个元素以生成新的迭代器。 这里的闭包创建了一个新的迭代器,对其中 vector 中的每个元素都被加 1。不过这些代码会产生一个警告:let v1: Vec<i32> = vec![1, 2, 3]; v1.iter().map(|x| x + 1); 得到的警告是:warning: unused `std::iter::Map` which must be used: iterator adaptors are lazy and do nothing unless consumed --> src/main.rs:4:5 | 4 | v1.iter().map(|x| x + 1); | ^^^^^^^^^^^^^^^^^^^^^^^^^ 警告提醒了我们:迭代器适配器是惰性的,而这里我们需要消费迭代器。为了修复这个警告并消费迭代器获取有用的结果,我们使用 collect 方法。这个方法消费迭代器并将结果收集到一个数据结构中。let v1: Vec<i32> = vec![1, 2, 3]; let v2: Vec<_> = v1.iter().map(|x| x + 1).collect(); assert_eq!(v2, vec![2, 3, 4]); map 获取一个闭包,可以指定任何希望在遍历的每个元素上执行的操作。使用闭包获取环境让我们展示一个通过使用 filter 迭代器适配器和捕获环境的闭包的用例。迭代器的 filter 方法获取一个使用迭代器的每一个项并返回布尔值的闭包。如果闭包返回 true,其值将会包含在 filter 提供的新迭代器中。如果闭包返回 false,其值不会包含在结果迭代器中struct Shoe { size: u32, style: String, } fn shoes_in_my_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> { shoes.into_iter() .filter(|s| s.size == shoe_size) .collect() } #[test] fn filters_by_size() { let shoes = vec![ Shoe { size: 10, style: String::from("sneaker") }, Shoe { size: 13, style: String::from("sandal") }, Shoe { size: 10, style: String::from("boot") }, ]; let in_my_size = shoes_in_my_size(shoes, 10); assert_eq!( in_my_size, vec![ Shoe { size: 10, style: String::from("sneaker") }, Shoe { size: 10, style: String::from("boot") }, ] ); } shoes_in_my_size 函数获取一个鞋子 vector 的所有权和一个鞋子大小作为参数。它返回一个只包含指定大小鞋子的 vector。shoes_in_my_size 函数体中调用了 into_iter 来创建一个获取 vector 所有权的迭代器。接着调用 filter 将这个迭代器适配成一个只含有那些闭包返回 true 的元素的新迭代器。实现 Iterator trait 来创建自定义迭代器可以实现 Iterator trait 来创建任何我们希望的迭代器。定义中唯一要求提供的方法就是 next 方法。一旦定义了它,就可以使用所有其他由 Iterator trait 提供的拥有默认实现的方法来创建自定义迭代器了!让我们创建一个只会从 1 数到 5 的迭代器。首先,创建一个结构体来存放一些值,接着实现 Iterator trait 将这个结构体放入迭代器中并在此实现中使用其值。struct Counter { count: u32, } impl Counter { fn new() -> Counter { Counter { count: 0 } } } Counter 结构体有一个字段 count。这个字段存放一个 u32 值,它会记录处理 1 到 5 的迭代过程中的位置。count 是私有的因为我们希望 Counter 的实现来管理这个值。new 函数通过总是从为 0 的 count 字段开始新实例来确保我们需要的行为。为 Counter 类型实现 Iterator trait,通过定义 next 方法来指定使用迭代器时的行为。impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { self.count += 1; if self.count < 6 { Some(self.count) } else { None } } } 将迭代器的关联类型 Item 设置为 u32,意味着迭代器会返回 u32 值集合。一旦实现了 Iterator trait,我们就有了一个迭代器!如下展示了一个测试用来演示使用 Counter 结构体的迭代器功能,通过直接调用 next 方法。fn calling_next_directly() { let mut counter = Counter::new(); assert_eq!(counter.next(), Some(1)); assert_eq!(counter.next(), Some(2)); assert_eq!(counter.next(), Some(3)); assert_eq!(counter.next(), Some(4)); assert_eq!(counter.next(), Some(5)); assert_eq!(counter.next(), None); }
0
0
0
浏览量1701
前端码农

你应该知晓的Rust Web 框架

前言在之前的用 Rust 搭建 React Server Components 的 Web 服务器我们利用了Axum构建了RSC的服务器。也算是用Rust在构建Web服务上的小试牛刀。虽然说Axum在Rust Web应用中一枝独秀。但是,市面上也有很多不同的解决方案。所以,今天我们就比较一些 Rust 框架,突出它们各自的优势和缺点,以帮助我们为项目做出明智的决策。没有对比就没有选择,我们只有在真正的了解各个框架的优缺点和适应场景,在以后的开发中才能有的放矢的放心选择。文本中,我们会介绍很多Rust框架。并且会按照如下的受欢迎程度的顺序来讲。好了,天不早了,干点正事哇。我们能所学到的知识点Axum Actix Web Rocket Warp Tide Poem1. AxumAxum 是 Rust 生态系统中具有特殊地位的 Web 应用程序框架(从下载量就可见端倪)。它是 Tokio 项目的一部分,Tokio 是使用 Rust 编写异步网络应用程序的运行时。Axum 不仅使用 Tokio 作为其异步运行时,还与 Tokio 生态系统的其他库集成,利用 Hyper 作为其 HTTP 服务器和 Tower 作为中间件。通过这样做,我们能够重用 Tokio 生态系统中现有的库和工具。Axum 不依赖于宏,而是利用 Rust 的类型系统提供安全且人性化的 API。这是通过使用特性来定义框架的核心抽象实现的,例如 Handler 特性,用于定义应用程序的核心逻辑。这种方法允许我们轻松地从较小的组件中组合应用程序,这些组件可以在多个应用程序中重用。在 Axum 中,处理程序(handler)是一个接受请求并返回响应的函数。这与其他后端框架类似,但使用 Axum 的 FromRequest 特性,我们可以指定从请求中提取的数据类型。返回类型需要实现 IntoResponse 特性(trait),已经有许多类型实现了这个特性,包括允许轻松更改响应的状态代码的元组类型。Rust 的类型系统、泛型,尤其是在traits中使用异步方法(或更具体地说是返回的 Future),当不满足trait限制时,Rust 的错误消息会很复杂。特别是当尝试匹配抽象trait限制时,经常会得到一堆难以解读的文本。为此Axum 提供了一个带有辅助宏的库,将错误放到实际发生错误的地方,使得更容易理解发生了什么错误。虽然Axum 做了很多正确的事情,可以很容易地启动执行许多任务的应用程序。但是,有一些事情需要特别注意。Axum版本仍然低于 1.0,也就意味着Axum 团队保留在版本之间根本性地更改 API 的自由,这可能导致我们的应用程序出现严重问题。Axum 示例下面展示了一个 WebSocket 处理程序,它会回显收到的任何消息。// #[tokio::main] 宏标记了 `main` 函数,表明这是一个异步的`Tokio`应用程序。 #[tokio::main] async fn main() { // 首先创建了一个 `TcpListener` 监听器,绑定到地址 "127.0.0.1:3000" 上 // 然后,通过 `await` 等待监听器绑定完成 // 如果绑定失败,会通过 `unwrap` 方法抛出错误。 let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("listening on {}", listener.local_addr().unwrap()); // 使用 `axum::serve` 启动 Axum 框架的服务器, // 监听前面创建的 `TcpListener`。 // `app()` 函数返回的是一个 `Router` // 它定义了一个简单的路由,将路径 "/a" 映射到处理函数 `a_handler`。 axum::serve(listener, app()).await.unwrap(); } // 返回一个 `Router`,它只有一个路由规则, // 将 "/a" 路径映射到 `a_handler` 处理函数 fn app() -> Router { Router::new() .route("/a", get(a_handler)) } // 一个WebSocket处理程序,它会回显收到的任何消息。 // 定义为一个WebSocket处理程序, // 它接收一个 `WebSocketUpgrade` 参数,表示WebSocket升级。 async fn a_handler(ws: WebSocketUpgrade) -> Response { // 调用将WebSocket升级后的对象传递给 `a_handle_socket` 处理函数。 ws.on_upgrade(a_handle_socket) } async fn a_handle_socket(mut socket: WebSocket) { // 使用 while let 循环,持续从 WebSocket 连接中接收消息。 // socket.recv().await 通过异步的方式接收消息,返回一个 Result, // 其中 Ok(msg) 表示成功接收到消息。 while let Some(Ok(msg)) = socket.recv().await { // 使用 if let 匹配,判断接收到的消息是否为文本消息。 // WebSocket消息可以是不同类型的,这里我们只处理文本消息。 if let Message::Text(msg) = msg { // 构造一个回显消息,将客户端发送的消息包含在回显消息中。 // 然后,使用 socket.send 方法将回显消息发送回客户端。 // await 等待发送操作完成。 if socket .send(Message::Text(format!("You said: {msg}"))) .await // 检查 send 操作是否返回错误。 // 如果发送消息出现错误(例如,连接断开), // 就通过 break 跳出循环,结束处理函数。 .is_err() { break; } } } } Axum 特点无宏 API。利用 Tokio、Tower 和 Hyper 构建强大的生态系统。出色的开发体验。仍处于 0.x 版本,因此可能发生重大变更。2. Actix WebActix Web 是 Rust 中存在已久且非常受欢迎的 Web 框架之一。像任何良好的开源项目一样,它经历了许多迭代,但已经达到了主要版本(不再是 0.x),换句话说:在主要版本内,它可以确保没有破坏性的更改。乍一看,Actix Web 与 Rust 中的其他 Web 框架非常相似。我们使用宏来定义 HTTP 方法和路由(类似于 Rocket),并使用提取器(extractors)从请求中获取数据(类似于 Axum)。与 Axum 相比,它们之间的相似之处显著,甚至在它们命名概念和特性的方式上也很相似。最大的区别是 Actix Web 没有将自己与Tokio 生态系统强关联在一起。虽然 Tokio 仍然是 Actix Web 底层的运行时,但是该框架具有自己的抽象和特性,以及自己的一套 crates 生态系统。这既有利有弊。一方面,我们可以确保事物能够很好地配合使用,另一方面,我们可能会错失 Tokio 生态系统中已经可用的许多功能。Actix Web 实现了自己的 Service 特性,它基本上与 Tower 的 Service 相同,但仍然不兼容。这意味着在 Tower 生态系统中大多数可用的中间件在 Actix 中不可用。如果在 Actix Web 中需要实现一些特殊任务,而需要自己实现,我们可能会碰到运行框架中的 Actor 模型。这可能会增加一些意想不到的问题。但 Actix Web 社区很给力。该框架支持 HTTP/2 和 WebSocket 升级,提供了用于 Web 框架中最常见任务的 crates 和指南,以及出色的文档,而且速度很快。Actix Web 之所以受欢迎,是有原因的,如果我们需要保证版本,请注意它可能是我们目前的最佳选择。Actix Web 示例在 Actix Web 中,一个简单的 WebSocket 回显服务器如下所示:use actix::{Actor, StreamHandler}; use actix_web::{ web, App, Error, HttpRequest, HttpResponse, HttpServer }; use actix_web_actors::ws; /// 定义HTTP Actor // 定义了一个名为 MyWs 的结构体,这将用作WebSocket的Actix Actor。 // Actors 是Actix框架中的并发单元,用于处理异步消息 struct MyWs; // 为 MyWs 结构体实现了 Actor trait,指定了 WebsocketContext 作为上下文类型。 impl Actor for MyWs { type Context = ws::WebsocketContext<Self>; } /// 处理ws::Message消息的处理程序 // 为 MyWs 结构体实现了 StreamHandler trait,处理WebSocket连接中的消息。 impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for MyWs { // 对接收到的不同类型的消息进行处理。例如,对于 Ping 消息,发送 Pong 消息作为响应。 fn handle(&mut self, msg: Result<ws::Message, ws::ProtocolError>, ctx: &mut Self::Context) { match msg { Ok(ws::Message::Ping(msg)) => ctx.pong(&msg), Ok(ws::Message::Text(text)) => ctx.text(text), Ok(ws::Message::Binary(bin)) => ctx.binary(bin), _ => (), } } } // 定义了一个处理HTTP请求的异步函数。 async fn index(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> { // 将WebSocket连接升级,并将请求委托给 MyWs Actor 处理。 let resp = ws::start(MyWs {}, &req, stream); println!("{:?}", resp); resp } #[actix_web::main] async fn main() -> std::io::Result<()> { // 创建了一个 HttpServer 实例,通过 App::new() 创建一个应用, // 该应用只有一个路由,将路径 "/ws/" 映射到处理函数 index 上。 HttpServer::new(|| App::new().route("/ws/", web::get().to(index))) // 绑定服务器到地址 "127.0.0.1" 和端口 8080。 .bind(("127.0.0.1", 8080))? // 启动服务器并等待其完成运行。 .run() .await } Actix Web 特点拥有强大的生态系统。基于 Actor 模型。通过主要版本保证的稳定 API。出色的文档。3. RocketRocket 在 Rust Web 框架生态系统中已经有一段时间了:它的主要特点是基于宏的路由、内置表单处理、对数据库和状态管理的支持,以及其自己版本的模板!Rocket 确实尽力做到构建 一个 Web 应用程序所需的一切。然而,Rocket 的雄心壮志也带来了一些代价。尽管仍在积极开发中,但发布的频率不如以前。这意味着框架的用户会错过许多重要的东西。此外,由于其一体化的方法,我们还需要了解 Rocket 的实现方式。Rocket 应用程序有一个生命周期,构建块以特定的方式连接,如果出现问题,我们需要理解问题出在哪里。Rocket 是一个很棒的框架,如果我们想开始使用 Rust 进行 Web 开发,它是一个很好的选择。对于我们许多人来说,Rocket 是进入 Rust 的第一步,使用它仍然很有趣。Rocket 示例处理表单的 Rocket 应用程序的简化示例:// 定义了一个名为 Password 的结构体,该结构体派生了 Debug 和 FromForm traits。 // FromForm trait 用于从表单数据中提取数据。 // 该结构体包含两个字段 first 和 second,分别表示密码的第一个和第二个部分。 #[derive(Debug, FromForm)] struct Password<'v> { // 表示对字段的长度进行了验证,要求长度在6个字符以上 #[field(validate = len(6..))] // 表示第一个字段必须等于第二个字段 #[field(validate = eq(self.second))] first: &'v str, // 表示第二个字段必须等于第一个字段。 #[field(validate = eq(self.first))] second: &'v str, } // 省略其他结构体和实现... // 定义了一个处理GET请求的函数 index,返回一个 Template 对象。 // 这个函数用于渲染首页。 #[get("/")] fn index() -> Template { Template::render("index", &Context::default()) } // 定义了一个处理POST请求的函数 submit。 // 这个函数接受一个 Form 对象,其中包含了表单的数据 #[post("/", data = "<form>")] fn submit(form: Form<Submit<'_>>) -> (Status, Template) { // 通过检查 form.value 是否包含 Some(ref submission) 来判断表单是否提交。 let template = match form.value { // 如果提交了表单,打印提交的内容,并渲染 "success" 页面; Some(ref submission) => { println!("submission: {:#?}", submission); Template::render("success", &form.context) } // 否则,渲染 "index" 页面。 None => Template::render("index", &form.context), }; (form.context.status(), template) } // 定义了启动Rocket应用程序的函数。 #[launch] fn rocket() -> _ { // 使用 rocket::build() 创建一个Rocket应用程序实例 rocket::build() // 并通过 .mount() 方法挂载路由。 // routes![index, submit] 定义了两个路由, // 分别映射到 index 和 submit 函数。 .mount("/", routes![index, submit]) // 添加了一个模板处理的Fairing(Rocket中的中间件) .attach(Template::fairing()) // 将静态文件服务挂载到根路径。 .mount("/", FileServer::from(relative!("/static"))) } Rocket 特点一体化的方法。出色的开发体验。开发活跃度不如以前。初学者的绝佳选择。4. WarpWarp 是一个构建在 Tokio 之上的 Web 框架,而且是一个非常好的框架。它与我们之前看到的其他框架非常不同。Warp 与 Axum 有一些共同的特点:它构建在 Tokio 和 Hyper 之上,并利用了 Tower 中间件。然而,它在方法上有很大的不同。Warp 是建立在 Filter trait 之上的。在 Warp 中,我们构建一系列应用于传入请求的过滤器,并将请求传递到管道直到达到末端。过滤器可以链接,它们可以组合。这使我们能够构建非常复杂的管道,但仍然易于理解。Warp 也比 Axum 更接近 Tokio 生态系统,这意味着我们可能会在没有任何粘合特性的情况下处理更多 Tokio 结构和概念。Warp 采用非常功能化的方法,如果这是我们的编程风格,我们将喜欢 Warp 的表达能力和可组合性。当我们查看 Warp 代码片段时,它通常读起来像正在发生的事情的故事,这在 Rust 中能够实现是有趣且令人惊讶的。然而,随着这些不同的函数和过滤器被链接在一起,Warp 中的类型变得非常长且非常复杂,而且难以理解。错误消息也是如此,可能是难以理解的一大堆文本。Warp 是一个很棒的框架。但是,它并不是最适合初学者的框架,也不是最流行的框架。这意味着我们可能在寻找帮助和资源方面会更加困难。但它非常适用于快速小型应用程序!Warp 示例来自其示例仓库的 WebSocket 聊天的 Warp 应用程序的简化示例:// 定义了一个静态的原子 usize 计数器,用于为每个连接的用户分配唯一的用户ID。 static NEXT_USER_ID: AtomicUsize = AtomicUsize::new(1); // 当前连接用户的状态。 // 定义了一个类型别名 Users,它是一个原子引用计数的可读写锁的 HashMap,将用户ID映射到消息的发送器。 // Arc 是原子引用计数的智能指针,RwLock 是读写锁。 // - 键是其id // - 值是`warp::ws::Message`的发送器 type Users = Arc<RwLock<HashMap<usize, mpsc::UnboundedSender<Message>>>>; #[tokio::main] async fn main() { // 创建了一个 users 变量,用于存储连接的用户信息 let users = Users::default(); // 将其包装成 Warp 过滤器,以便在不同的路由中共享用户状态。 let users = warp::any().map(move || users.clone()); // chat 路由处理 WebSocket 握手 let chat = warp::path("chat") // `ws()`过滤器将准备WebSocket握手... .and(warp::ws()) .and(users) // 调用 user_connected 函数处理 WebSocket 连接。 .map(|ws: warp::ws::Ws, users| { // 如果握手成功,将调用我们的函数。 ws.on_upgrade(move |socket| user_connected(socket, users)) }); // 处理 HTTP GET 请求,返回一个包含聊天室链接的 HTML 页面 let index = warp::path::end().map(|| warp::reply::html(INDEX_HTML)); let routes = index.or(chat); warp::serve(routes).run(([127, 0, 0, 1], 3030)).await; } async fn user_connected(ws: WebSocket, users: Users) { // 使用计数器为此用户分配新的唯一ID。 let my_id = NEXT_USER_ID.fetch_add(1, Ordering::Relaxed); eprintln!("new chat user: {}", my_id); // 将套接字拆分为消息的发送器和接收器。 let (mut user_ws_tx, mut user_ws_rx) = ws.split(); // 创建一个新的消息通道 (mpsc::unbounded_channel) 用于将用户的消息广播给其他用户 let (tx, rx) = mpsc::unbounded_channel(); let mut rx = UnboundedReceiverStream::new(rx); tokio::task::spawn(async move { // 不断接收用户的消息。一旦用户断开连接,就会退出这个循环。 while let Some(message) = rx.next().await { user_ws_tx .send(message) .unwrap_or_else(|e| { eprintln!("websocket send error: {}", e); }) .await; } }); //将发送器保存在我们的已连接用户列表中。 users.write().await.insert(my_id, tx); // 返回一个基本上是管理此特定用户连接的状态机的“Future”。 // 每当用户发送消息时,将其广播给 // 所有其他用户... while let Some(result) = user_ws_rx.next().await { let msg = match result { Ok(msg) => msg, Err(e) => { eprintln!("websocket error(uid={}): {}", my_id, e); break; } }; user_message(my_id, msg, &users).await; } // 只要用户保持连接,user_ws_rx流就会继续处理。一旦他们断开连接,那么... user_disconnected(my_id, &users).await; } // 处理用户发送的消息。它跳过非文本消息,将文本消息格式化为 <User#ID>: Message,然后将其广播给所有其他用户。 async fn user_message(my_id: usize, msg: Message, users: &Users) { // 跳过任何非文本消息... let msg = if let Ok(s) = msg.to_str() { s } else { return; }; let new_msg = format!("<User#{}>: {}", my_id, msg); // 来自此用户的新消息,将其发送给所有其他用户(除了相同的uid)... for (&uid, tx) in users.read().await.iter() { if my_id != uid { if let Err(_disconnected) = tx.send(Message::text(new_msg.clone())) { // 发送器已断开连接,我们的`user_disconnected`代码 // 应该在另一个任务中执行,这里没有更多的事情要做。 } } } } async fn user_disconnected(my_id: usize, users: &Users) { eprintln!("good bye user: {}", my_id); // 流关闭,因此从用户列表中删除 users.write().await.remove(&my_id); } Warp 特点函数式方法。良好的表达能力。通过接近 Tokio、Tower 和 Hyper 构建强大的生态系统。不适合初学者的框架5. TideTide 是一个建立在 async-std 运行时之上的极简主义 Web 框架。极简主义的方法意味着我们得到了一个非常小的 API 表面。Tide 中的处理函数是 async fn,接受一个 Request 并返回一个 Response 的 tide::Result。提取数据或发送正确的响应格式由我们自行完成。虽然这可能对我们来说是更多的工作,但也更直接,意味着我们完全掌控正在发生的事情。在某些情况下,能够离 HTTP 请求和响应如此近是一种愉悦,使事情变得更容易。Tide 的中间件方法与我们从 Tower 中了解的类似,但 Tide 公开了 async trait crate,使实现变得更加容易。Tide 示例来自其示例仓库的用户会话示例:// async-std crate 提供的异步 main 函数。它返回一个 Result,表示可能的错误。 #[async_std::main] async fn main() -> Result<(), std::io::Error> { // 使用 femme crate 启用颜色日志。这是一个美观的日志记录库,可以使日志输出更易读。 femme::start(); // 创建一个 Tide 应用程序实例 let mut app = tide::new(); // 添加一个日志中间件,用于记录请求和响应的日志信息。 app.with(tide::log::LogMiddleware::new()); // 添加一个会话中间件,用于处理会话数据。这里使用内存存储,并提供一个密钥(TIDE_SECRET),用于加密和验证会话数据。 app.with(tide::sessions::SessionMiddleware::new( tide::sessions::MemoryStore::new(), std::env::var("TIDE_SECRET") .expect( "Please provide a TIDE_SECRET value of at \ least 32 bytes in order to run this example", ) .as_bytes(), )); // 添加一个 Before 中间件,它在处理请求之前执行。在这里,它用于增加访问计数,存储在会话中。 app.with(tide::utils::Before( |mut request: tide::Request<()>| async move { let session = request.session_mut(); let visits: usize = session.get("visits").unwrap_or_default(); session.insert("visits", visits + 1).unwrap(); request }, )); // 定义了一个处理根路径的GET请求的路由。这个路由通过 async move 来处理请求,获取会话中的访问计数,并返回一个包含访问次数的字符串。 app.at("/").get(|req: tide::Request<()>| async move { let visits: usize = req.session().get("visits").unwrap(); Ok(format!("you have visited this website {} times", visits)) }); // 定义了一个处理 "/reset" 路径的GET请求的路由。这个路由通过 async move 处理请求,将会话数据清除,然后重定向到根路径 app.at("/reset") .get(|mut req: tide::Request<()>| async move { req.session_mut().destroy(); Ok(tide::Redirect::new("/")) }); // 启动应用程序并监听在 "127.0.0.1:8080" 地址上。使用 await? 处理可能的启动错误。 app.listen("127.0.0.1:8080").await?; Ok(()) } Tide 简要概述极简主义方法。使用 async-std 运行时。简单的处理函数。异步特性的试验场。6. PoemPoem 声称自己是一个功能齐全但易于使用的 Web 框架。乍一看,它的使用方式与 Axum 非常相似,唯一的区别是它需要使用相应的宏标记处理程序函数。它还建立在 Tokio 和 Hyper 之上,完全兼容 Tower 中间件,同时仍然暴露自己的中间件特性。Poem 的中间件特性也非常简单易用。我们可以直接为所有或特定的 Endpoint(Poem 表达一切都可以处理 HTTP 请求的方式)实现该特性,或者只需编写一个接受 Endpoint 作为参数的异步函数。Poem 不仅与更广泛的生态系统中的许多功能兼容,而且还具有丰富的功能,包括对 OpenAPI 和 Swagger 文档的全面支持。它不仅限于基于 HTTP 的 Web 服务,还可以用于基于 Tonic 的 gRPC 服务,甚至在 Lambda 函数中使用,而无需切换框架。添加对 OpenTelemetry、Redis、Prometheus 等的支持,我们就可以勾选所有现代企业级应用程序 Web 框架的所有框。Poem 仍然处于 0.x 版本,但如果保持势头并交付出色的 1.0 版本,这将是一个值得关注的框架!Poem 示例来自其示例仓库的 WebSocket 聊天的缩写版本:// 注解表示这是一个处理器函数,用于处理 WebSocket 请求 #[handler] fn ws( // 提取了 WebSocket 路径中的名字参数 Path(name): Path<String>, // WebSocket 对象,表示与客户端的连接 ws: WebSocket, // 是一个数据提取器,用于获取广播通道的发送器。 sender: Data<&tokio::sync::broadcast::Sender<String>>, ) -> impl IntoResponse { // 克隆了广播通道的发送器 sender。 let sender = sender.clone(); // 它订阅了广播通道,创建了一个接收器 receiver let mut receiver = sender.subscribe(); // 处理 WebSocket 连接升级 ws.on_upgrade(move |socket| async move { // 将连接的读写部分拆分为 sink 和 stream let (mut sink, mut stream) = socket.split(); // 从 WebSocket 客户端接收消息 // 如果是文本消息,则将其格式化为 {name}: {text} 的形式,并通过广播通道发送。 // 如果发送失败(例如,通道关闭),则任务终止。 tokio::spawn(async move { while let Some(Ok(msg)) = stream.next().await { if let Message::Text(text) = msg { if sender.send(format!("{name}: {text}")).is_err() { break; } } } }); // 从广播通道接收消息,并将其发送到 WebSocket 客户端 tokio::spawn(async move { while let Ok(msg) = receiver.recv().await { if sink.send(Message::Text(msg)).await.is_err() { break; } } }); }) } #[tokio::main] async fn main() -> Result<(), std::io::Error> { // 使用 tide::Route 创建了一个路由,其中包括两个路径: // - / 路径处理 HTTP GET 请求,调用 index 函数。 // - /ws/:name 路径处理 WebSocket 请求,调用 ws 函数。 let app = Route::new().at("/", get(index)).at( "/ws/:name", // 通过 tokio::sync::broadcast::channel 创建一个广播通道; // 并通过 tokio::sync::broadcast::channel::<String>(32).0 // 获取其发送器,将其作为数据传递给 ws 处理函数 get(ws.data(tokio::sync::broadcast::channel::<String>(32).0)), ); // 创建了一个服务器实例 Server::new(TcpListener::bind("127.0.0.1:3000")) // 启动服务器,并等待其完成运行。 .run(app) .await } Poem 简要概述丰富的功能集。与 Tokio 生态系统兼容。易于使用。适用于 gRPC 和 Lambda。
0
0
0
浏览量2012
前端码农

Rust学习笔记之所有权

我们的不快乐,是不是来源于自己对自己的苛刻,我们的人生要努力到什么程度,才可以不努力?今天,我们继续Rust学习笔记的探索。我们来谈谈关于所有权的相关知识点。如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。你能所学到的知识点所有权的概念 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️引用与借用 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️切片 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️好了,天不早了,干点正事哇。{所有权|ownership}可以说Rust中最为独特的一个功能,正是所有权概念和相关工具的引入,Rust才能够在没有垃圾回收机制的前提下保障内存安全。什么是所有权一般来讲,所有的程序都需要管理自己在运行时使用的计算机内存空间。某些使用垃圾回收机制的语言会在运行时定期检查并回收那些没有被继续使用的内存而在另外一些语言中,程序员需要手动地分配和释放内存。Rust采用了第三种方式:它使用特定规则的所有权系统来管理内存。所有权规则Rust中每一个值都有一个对应的变量作为它的所有者在同一时间内,值有且仅有一个所有者当所有者离开自己的作用域时,它持有的值就会被释放变量作用域简单来讲,作用域是一个对象在程序中有效的范围。假设有这样一个变量:let s = "hello"; 这里的变量s指向了一个字符串字面量,它的值被硬编码到了当前的程序中。变量从声明的位置开始直到当前作用域结束都是有效的。下面是针对一个变量s作用域的说明fn main() {// 变量s还未被声明,所以它在这里是不可用的 let s ="hello"; // 从这里开始变量s变得可用 // 执行与s相关的操作 } // 作用域到这里结束,变量s再次不可用 这里有两个重点:s在进入作用域后变得有效它会保持自己的有效性直到自己离开作用域为止String 类型之前接触的那些数据类型会将数据存储在栈上,并在离开自己的作用域时将数据弹出栈空间。我们需要一个存储在堆上的数据类型来研究Rust是如何自动回收这些数据结构的。我们将以String类型为例,并将注意力集中到String类型与所有权概念相关的部分。Rust提供了一种字符串类型String。这个类型在堆上分配到自己需要的存储空间,所以它能够处理在编译时未知大小的文本。可以调用from函数根据字符串字面量来创建一个String实例:let s = String::from("hello"); 这里的双冒号(::)运算符允许我们调用置于String命令空间下的特定方法from函数。上面定义的字符串对象能够被声明为可变的fn main() { let mut s = String::from("hello"); s.push_str(", world"); println!("{}",s) } 输出结果为hello, world内存和分配对于字符串字面量而言,由于我们在编译时就知道其内容,所有这部分硬编码的文本被直接嵌入到了最终的可执行文件中。这就是访问字符串字面量异常高效的原因,而这些性质完全得益于字符串字面量的不可变性。不幸的是,我们没有办法将那些未知大小的文本在编译期统统放入二进制文件中。对于String类型而言,为了支持一个可变的、可增长的文本类型,我们需要在堆上分配一块在编译时未知大小的内存来存放数据。这就意味着:使用的内存由操作系统在运行时动态分配出来当使用完String时,需要通过某种方式将这些内存归还给操作系统这里的第一步由程序的编写者,在调用String::from时完成,这个函数会请求自己需要的内存空间。也就是说程序员来发起堆内存的分配请求。针对与第二步,Rust提供了和其余GC机制不同的解决方案:内存会自动地在拥有它的变量离开作用域后进行释放。fn main() {// 变量s还未被声明,所以它在这里是不可用的 let s ="hello"; // 从这里开始变量s变得可用 // 执行与s相关的操作 } // 作用域到这里结束,变量s再次不可用 观察上面的代码,有一个很合适用来回收内存给操作系统的地方:变量s离开作用域的地方。Rust在变量离开作用域时,会调用一个叫做drop的特殊函数。Rust会在作用域结束的地方自动调用drop函数。变量和数据交互的方式:移动Rust中多个变量可以采用一种独特的方式与同一数据进行交互。let x = 5; let y = x; 将变量x的绑定的值重新绑定到变量y上。上面的代码中,将整数值5绑定到变量x上;然后创建一个x值的拷贝,并将它绑定到y上。结果我们有了两个变量x和y,它们的值都是5。 因为整数是已知固定大小的简单值,两个值会同时被推入当前的栈中。我们请上面的程序改造,变成String版本的let s1 = String::from("hello"); let s2 = s2; String 由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针一个长度:一个容量这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。当我们将 s1 赋值给 s2,String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。之前我们提到过当变量离开作用域后,Rust 自动调用 drop 函数并清理变量的堆内存。不过上图展示了两个数据指针指向了同一位置。这就有了一个问题:当 s2 和 s1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 {二次释放|double free}的错误。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。为了确保内存安全,这种场景下 Rust 的处理有另一个细节值得注意。在 let s2 = s1 之后,Rust 认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西。在 s2 被创建之后尝试使用 s1 会发生什么;这段代码不能运行。let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); 如果你在其他语言中听说过术语 {浅拷贝|shallow copy}和 {深拷贝|deep copy},那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为 {移动|move},而不是浅拷贝。上面的例子可以解读为 s1 被 移动 到了 s2 中。那么具体发生了什么,如下图所示。Rust 永远也不会自动创建数据的 “深拷贝”变量与数据交互的方式:克隆如果我们确实需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2); } 只在栈上的数据:拷贝fn main() { let x = 5; let y = x; println!("x = {}, y = {}", x, y); } 没有调用 clone,不过 x 依然有效且没有被移动到 y 中。原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同。Rust 有一个叫做 Copy trait 的特殊标注,可以用在类似整型这样的存储在栈上的类型上。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:所有整数类型,比如 u32。布尔类型,bool,它的值是 true 和 false。所有浮点数类型,比如 f64。字符类型,char。元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。所有权与函数将值传递给函数在语义上与给变量赋值相似。向函数传递值可能会移动或者复制,就像赋值语句一样。fn main() { let s = String::from("hello"); // s 进入作用域 takes_ownership(s); // s 的值移动到函数里 ... // ... 所以到这里不再有效 let x = 5; // x 进入作用域 makes_copy(x); // x 应该移动函数里, // 但 i32 是 Copy 的,所以在后面可继续使用 x } // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走, // 所以不会有特殊操作 fn takes_ownership(some_string: String) { // some_string 进入作用域 println!("{}", some_string); } // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放 fn makes_copy(some_integer: i32) { // some_integer 进入作用域 println!("{}", some_integer); } // 这里,some_integer 移出作用域。不会有特殊操作 当尝试在调用 takes_ownership 后使用 s 时,Rust 会抛出一个编译时错误。返回值与作用域返回值也可以转移所有权。fn main() { let s1 = gives_ownership(); // gives_ownership 将返回值 // 移给 s1 let s2 = String::from("hello"); // s2 进入作用域 let s3 = takes_and_gives_back(s2); // s2 被移动到 // takes_and_gives_back 中, // 它也将返回值移给 s3 } // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走, // 所以什么也不会发生。s1 移出作用域并被丢弃 fn gives_ownership() -> String { // gives_ownership 将返回值移动给 // 调用它的函数 let some_string = String::from("yours"); // some_string 进入作用域 some_string // 返回 some_string 并移出给调用的函数 } // takes_and_gives_back 将传入字符串并返回该值 fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域 a_string // 返回 a_string 并移出给调用的函数 } 变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它 当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。引用与借用下面是如何定义并使用一个 calculate_length 函数,它以一个对象的引用作为参数而不是获取值的所有权:fn main() { let s1 = String::from("hello"); let len = calculate_length(&s1); println!("The length of '{}' is {}.", s1, len); } fn calculate_length(s: &String) -> usize { s.len() } 这些 & 符号就是引用,它们允许你使用值但不获取其所有权。仔细看看这个函数调用:let s1 = String::from("hello"); let len = calculate_length(&s1); &s1 语法让我们创建一个指向值 s1 的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。同理,函数签名使用 & 来表明参数 s 的类型是一个引用。让我们增加一些解释性的注释:fn calculate_length(s: &String) -> usize { // s 是对 String 的引用 s.len() } // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权, // 所以什么也不会发生 变量 s 有效的作用域与函数参数的作用域一样,不过当引用停止使用时并不丢弃它指向的数据,因为我们没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。将创建一个引用的行为称为 {借用|Borrowing}。如果我们尝试修改借用的变量呢?结果是:这行不通!fn main() { let s = String::from("hello"); change(&s); } fn change(some_string: &String) { some_string.push_str(", world"); } 正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。可变引用fn main() { let mut s = String::from("hello"); change(&mut s); } fn change(some_string: &mut String) { some_string.push_str(", world"); } 首先,我们必须将 s 改为 mut。然后必须在调用 change 函数的地方创建一个可变引用 &mut s,并更新函数签名以接受一个可变引用 some_string: &mut String。这就非常清楚地表明,change 函数将改变它所借用的值。不过可变引用有一个很大的限制:在同一时间,只能有一个对某一特定数据的可变引用。尝试创建两个可变引用的代码将会失败:fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); } 这个报错说这段代码是无效的,因为我们不能在同一时间多次将 s 作为可变变量借用。第一个可变的借用在 r1 中,并且必须持续到在 println! 中使用它,但是在那个可变引用的创建和它的使用之间,我们又尝试在 r2 中创建另一个可变引用,它借用了与 r1 相同的数据。这个限制的好处是 Rust 可以在编译时就避免数据竞争。{数据竞争|Data Race}类似于竞态条件,它可由这三个行为造成: 两个或更多指针同时访问同一数据。 至少有一个指针被用来写入数据。 没有同步数据访问的机制。数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译期间存在数据竞争的代码!可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能同时 拥有:fn main() { let mut s = String::from("hello"); { let r1 = &mut s; } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用 let r2 = &mut s; } rust也不能在拥有不可变引用的同时拥有可变引用fn main() { let mut s = String::from("hello"); let r1 = &s; // 没问题 let r2 = &s; // 没问题 let r3 = &mut s; // 大问题 println!("{}, {}, and {}", r1, r2, r3); } 不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。一个引用的作用域从声明的地方开始一直持续到最后一次使用为止fn main() { let mut s = String::from("hello"); let r1 = &s; // 没问题 let r2 = &s; // 没问题 println!("{} and {}", r1, r2); // 此位置之后 r1 和 r2 不再使用 let r3 = &mut s; // 没问题 println!("{}", r3); } 不可变引用 r1 和 r2 的作用域在 println! 最后一次使用之后结束,这也是创建可变引用 r3 的地方。它们的作用域没有重叠,所以代码是可以编译的。{悬垂引用|Dangling References}在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个{悬垂引用|Dangling References},所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:fn main() { let reference_to_nothing = dangle(); } fn dangle() -> &String { let s = String::from("hello"); &s } 让我们仔细看看我们的 dangle 代码的每一步到底发生了什么:fn dangle() -> &String { // dangle 返回一个字符串的引用 let s = String::from("hello"); // s 是一个新字符串 &s // 返回字符串 s 的引用 } // 这里 s 离开作用域并被丢弃。其内存被释放。 // 危险! 因为 s 是在 dangle 函数内创建的,当 dangle 的代码执行完毕后,s 将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String,Rust 不会允许我们这么做。这里的解决方法是直接返回 String:fn no_dangle() -> String { let s = String::from("hello"); s } 所有权被移动出去,所以没有值被释放。引用的规则在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。引用必须总是有效的。切片 slice另一个没有所有权的数据类型是 slice。slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。字符串 slice字符串 slice(string slice)是 String 中一部分值的引用。fn main() { let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; } 这类似于引用整个 String 不过带有额外的 [0..5] 部分。它不是对整个 String 的引用,而是对部分 String 的引用。可以使用一个由中括号中的 [starting_index..ending_index] 指定的 range 创建一个 slice,其中 starting_index 是 slice 的第一个位置,ending_index 则是 slice 最后一个位置的后一个值。在其内部,slice 的数据结构存储了 slice 的开始位置和长度,长度对应于 ending_index 减去 starting_index 的值。所以对于 let world = &s[6..11]; 的情况,world 将是一个包含指向 s 索引 6 的指针和长度值 5 的 slice。对于 Rust 的 .. range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:fn main() { let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2]; } 如果 slice 包含 String 的最后一个字节,也可以舍弃尾部的数字。fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[3..len]; let slice = &s[3..]; } 也可以同时舍弃这两个值来获取整个字符串的 slicefn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[0..len]; let slice = &s[..]; } 字符串字面量就是 slice字符串字面量被储存在二进制文件中。fn main() { let s = "Hello, world!"; } 这里 s 的类型是 &str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面量是不可变的;&str 是一个不可变引用。其他类型的 slice字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组:fn main() { let a = [1, 2, 3, 4, 5]; } 就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分。fn main() { let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; } 这个 slice 的类型是 &[i32]。它跟字符串 slice 的工作方式一样,通过存储第一个集合元素的引用和一个集合总长度。
0
0
0
浏览量1391
前端码农

从0到1,带你搭建Vite+Vue3+Unocss+Pinia+Naive UI后台(二)配置篇下

配置篇配置篇分上中下三部分,本篇主要介绍eslint+prettier环境配置 + vite配置插件配置eslint+prettier配置集成eslint一、安装vscode插件 eslint二、安装依赖:eslint、eslint-plugin-vuepnpm i eslint eslint-plugin-vue -D三、在项目根路径下新建文件 .eslintrc.js.eslintrc.jsmodule.exports = { root: true, extends: ['plugin:vue/vue3-recommended'], rules: { 'vue/valid-template-root': 'off', 'vue/no-multiple-template-root': 'off', 'vue/multi-word-component-names': [ 'error', { ignores: ['index'], }, ], }, }点开 App.vue 会看到出现了很多警告或者错误,说明eslint已经生效了但是如果要让我们一个个去修复这些警告或者错误那就太麻烦了,有没有办法找出所有的警告并且修复呢,答案肯定是有的四、在 package.json 文件添加两个npm执行指令package.jsonscripts: { ... "lint": "eslint --ext .js,.vue .", "lint:fix": "eslint --fix --ext .js,.vue .", }执行 pnpm run lint 会在控制台列出所有的eslint警告项和错误项执行 pnpm run lint:fix 则可以将所有的eslint警告项和错误项自动修复(仅限于代码风格类的错误和警告)但是还是感觉不够爽,每次要修复的时候还得去执行指令,可不可以保存的时候进行自动修复呢?五、在.vscode目录下新建 settings.json 文件(也可以直接修改vscode的配置)settings.json{ "eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"], "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "editor.formatOnSave": false, } 然后就可以敲代码的过程中保存直接进行eslint修复了集成prettier按上面的配置会发现对.js文件和.vue文件中的js代码和css代码没有任何约束力,这明显不是我们需要的效果,这时就需要prettier登场了一、安装依赖项 prettier、eslint-config-prettier、eslint-plugin-prettierpnpm i prettier eslint-config-prettier eslint-plugin-prettier -D二、在项目根路径创建文件 prettier.config.jsprettier.config.jsmodule.exports = { endOfLine: 'lf', printWidth: 120, singleQuote: true, semi: false, }三、修改 .eslintrc.js.eslintrc.jsmodule.exports = { root: true, extends: ['plugin:vue/vue3-recommended', 'plugin:prettier/recommended'], rules: { 'prettier/prettier': 'warn', 'vue/valid-template-root': 'off', 'vue/no-multiple-template-root': 'off', 'vue/multi-word-component-names': [ 'error', { ignores: ['index', '401', '404'], }, ], }, }配置完就会发现在.js文件和.vue文件中不管代码有多乱,Ctrl + S 都能让你的代码立即变得干净整洁,简直不要太爽如发现配置未生效可重新启动vscode试试补充配置在项目中我们最好是使用统一行尾符(建议不管还是mac还是windows都使用lf),但是按上面的配置,我们发现保存的时候无法将crlf行尾符转换成lf行尾符,当然我们可以直接点击vscode的右下角切换行尾符,但终究是有点麻烦,这时使用.editorconfig就很有必要了在项目根路径新建文件 .editorconfig.editorconfigroot = true [*] charset = utf-8 end_of_line = lf这时候保存的时候就可以直接转换成lf行尾符了,当然.editorconfig的作用不仅仅于此,配置得当甚至可以替换eslint和prettier,而且其配置还是跨平台和跨编辑器的那可不可以在新建文件的时候就确定好使用lf呢,当然也是可以的.vscode/settings.json{ ... "files.eol": "\n", }eslint+prettier配置篇就介绍完了,我想说的是这套配置并不是一套绝对标准的配置,但是是我目前用得最舒服的配置,也是我目前个人项目一直在用的配置,有一定的约束力又不会用起来难受。更多eslint rules配置请参考 List of available rules - ESLint中文
0
0
0
浏览量2014
前端码农

从0到1,带你搭建Vite+Vue3+Unocss+Pinia+Naive UI后台(二)配置篇中

配置篇配置篇分上中下三部分,本篇主要介绍插件配置环境配置 + vite配置插件配置eslint+prettier配置插件配置本篇将介绍如何集成以下几个插件:vite-plugin-vue-setup-extend:扩展setup插件,支持在script标签中使用name属性rollup-plugin-visualizer:rollup打包分析插件vite-plugin-html:一个针对 index.html,提供压缩和基于 ejs 模板功能的 vite 插件unocss: 出自antfu的原子化css在后续文章中也会按进度集成图标插件、组件库按需引入插件及mock插件集成 vite-plugin-vue-setup-extend第一步:安装vite-plugin-vue-setup-extendpnpm i vite-plugin-vue-setup-extend -D第二步:在build文件夹下创建plugin/index.jsbuild/plugin/index.jsimport vue from '@vitejs/plugin-vue' /** * * 扩展setup插件,支持在script标签中使用name属性 * usage: <script setup name="MyComp"></script> */ import VueSetupExtend from 'vite-plugin-vue-setup-extend' export function createVitePlugins(viteEnv, isBuild) { const plugins = [ vue(), VueSetupExtend(), ] return plugins }第三步: 修改vite.config.jsvite.config.jsimport { defineConfig, loadEnv } from 'vite' import path from 'path' import { wrapperEnv, createProxy } from './build/utils' import { createVitePlugins } from './build/plugin' export default defineConfig(({ command, mode }) => { const isBuild = command === 'build' const env = loadEnv(mode, process.cwd()) const viteEnv = wrapperEnv(env) const { VITE_PORT, VITE_PUBLIC_PATH, VITE_PROXY } = viteEnv return { plugins: createVitePlugins(viteEnv, isBuild), base: VITE_PUBLIC_PATH || '/', resolve: { // 设置别名 alias: { '@': path.resolve(__dirname, 'src'), }, }, css: { preprocessorOptions: { //define global scss variable scss: { additionalData: `@import '@/styles/variables.scss';`, }, }, }, server: { host: '0.0.0.0', // 默认为'127.0.0.1',如果将此设置为 `0.0.0.0` 或者 `true` 将监听所有地址,包括局域网和公网地址 port: VITE_PORT, // 端口 proxy: createProxy(VITE_PROXY), // 代理 } } }) 文件修改处如下图所示:集成rollup-plugin-visualizer第一步:安装rollup-plugin-visualizerpnpm i rollup-plugin-visualizer -D第二步:修改build/plugin/index.jsbuild/plugin/index.jsimport vue from '@vitejs/plugin-vue' /** * * 扩展setup插件,支持在script标签中使用name属性 * usage: <script setup name="MyComp"></script> */ import VueSetupExtend from 'vite-plugin-vue-setup-extend' // rollup打包分析插件 import visualizer from 'rollup-plugin-visualizer' export function createVitePlugins(viteEnv, isBuild) { const plugins = [ vue(), VueSetupExtend(), ] if (isBuild) { plugins.push( visualizer({ open: true, gzipSize: true, brotliSize: true, }) ) } return plugins }第三步:打包验证下插件是否生效pnpm run build正常应该会在根目录产生一个stats.html文件,通过浏览器打开这个文件,会看到如下页面,由于目前还算是一个空项目,所有还没有太多的依赖项第四步:将stats.html添加到git忽略项.gitignore... stats.htmlstats.html每次打包都会生成一个新的,无需通过git添加提交集成vite-plugin-html集成 vite-plugin-html 主要是为了对 index.html 进行压缩和注入动态数据,例如替换网站标题和cdn第一步:安装vite-plugin-htmlpnpm i vite-plugin-html@2 -D第二步:创建build/plugin/html.jsbuild/plugin/html.jsimport html from 'vite-plugin-html' export function configHtmlPlugin(viteEnv, isBuild) { const { VITE_APP_TITLE } = viteEnv const htmlPlugin = html({ minify: isBuild, inject: { data: { title: VITE_APP_TITLE, }, }, }) return htmlPlugin }第三步:修改build/plugin/index.jsbuild/plugin/index.jsimport vue from '@vitejs/plugin-vue' /** * * 扩展setup插件,支持在script标签中使用name属性 * usage: <script setup name="MyComp"></script> */ import VueSetupExtend from 'vite-plugin-vue-setup-extend' // rollup打包分析插件 import visualizer from 'rollup-plugin-visualizer' import { configHtmlPlugin } from './html' export function createVitePlugins(viteEnv, isBuild) { const plugins = [ vue(), VueSetupExtend(), configHtmlPlugin(viteEnv, isBuild), ] if (isBuild) { plugins.push( visualizer({ open: true, gzipSize: true, brotliSize: true, }) ) } return plugins } 第四步:修改 index.html 的title,并重新启动,验证插件是否集成成功<title><%= title %></title>如无意外将看到页面的title已经被替换成我们配置好的title了集成unocss第一步:安装依赖pnpm i unocss @unocss/preset-attributify @unocss/preset-icons @unocss/preset-uno -D第二步:新建文件 build/plugin/unocss.jsbuild/plugin/unocss.jsimport Unocss from 'unocss/vite' import { presetUno, presetAttributify, presetIcons } from 'unocss' export function unocss() { return Unocss({ presets: [presetUno(), presetAttributify(), presetIcons()], }) }第三步:修改文件 build/plugin/index.jsbuild/plugin/index.jsimport vue from '@vitejs/plugin-vue' /** * * 扩展setup插件,支持在script标签中使用name属性 * usage: <script setup name="MyComp"></script> */ import VueSetupExtend from 'vite-plugin-vue-setup-extend' // rollup打包分析插件 import visualizer from 'rollup-plugin-visualizer' import { configHtmlPlugin } from './html' import { unocss } from './unocss' export function createVitePlugins(viteEnv, isBuild) { const plugins = [ vue(), VueSetupExtend(), configHtmlPlugin(viteEnv, isBuild), unocss() ] if (isBuild) { plugins.push( visualizer({ open: true, gzipSize: true, brotliSize: true, }) ) } return plugins } 第四步:新建 styles/reset.scss、styles/public.scss、styles/index.scssstyles/reset.scsshtml { box-sizing: border-box; } *, ::before, ::after { margin: 0; padding: 0; box-sizing: inherit; } a { text-decoration: none; color: #333; } a:hover, a:link, a:visited, a:active { text-decoration: none; } ol, ul { list-style: none; } input, textarea { outline: none; border: none; resize: none; } body { font-size: 14px; font-weight: 400; } styles/public.scsshtml { font-size: 4px; // * 1rem = 4px 方便unocss计算:在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px } html, body { width: 100%; height: 100%; overflow: hidden; background-color: #f2f2f2; font-family: 'Encode Sans Condensed', sans-serif; } /* 滚动条样式 */ ::-webkit-scrollbar { width: 8px; background-color: #eee; } ::-webkit-scrollbar-thumb { background-color: #c1c1c1; &:hover { background-color: #a8a8a8; } } styles/index.scss@import './reset.scss'; @import './public.scss'; 第步:修改 src/main.js 引入uno.csssrc/main.jsimport '@/styles/index.scss' import 'uno.css' import { createApp } from 'vue' import App from './App.vue' createApp(App).mount('#app') 第五步:修改 src/App.vue ,使用unocss以验证unocss是否集成成功src/App.vue<template> <div p-24> <p> 文档:<a hover-decoration-underline c-blue href="https://uno.antfu.me/" target="_blank">https://uno.antfu.me/</a> </p> <p> playground: <a c-blue hover-decoration-underline href="https://unocss.antfu.me/play/" target="_blank"> https://unocss.antfu.me/play/ </a> </p> <div flex mt-20> <div flex p-20 rounded-5 bg-white> <div text-20 font-600>Flex布局</div> <div flex w-360 flex-wrap justify-around ml-15 p-10> <div w-50 h-50 b-1 rounded-5 flex justify-center items-center p-10 m-20> <span w-6 h-6 rounded-3 bg-black></span> </div> <div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black self-end></span> </div> <div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black self-center></span> <span w-6 h-6 rounded-3 bg-black self-end></span> </div> <div w-50 h-50 b-1 rounded-5 flex justify-between p-10 m-20> <div flex flex-col justify-between> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black></span> </div> <div flex flex-col justify-between> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black></span> </div> </div> <div w-50 h-50 b-1 rounded-5 flex flex-col justify-between items-center p-10 m-20> <div flex w-full justify-between> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black></span> </div> <span w-6 h-6 rounded-3 bg-black></span> <div flex w-full justify-between> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black></span> </div> </div> <div w-50 h-50 b-1 rounded-5 flex flex-col justify-between p-10 m-20> <div flex w-full justify-between> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black></span> </div> <div flex w-full justify-between> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black></span> </div> <div flex w-full justify-between> <span w-6 h-6 rounded-3 bg-black></span> <span w-6 h-6 rounded-3 bg-black></span> </div> </div> </div> </div> <div flex ml-35 p-20 rounded-5 bg="#fff"> <div text-20 font-600>字体:</div> <div ml-15 p-10 pl-30 pr-30 rounded-5> <p text-12>font-size: 12px</p> <p text-16>font-size: 16px</p> <p text-20>font-size: 20px</p> <p font-300 mt-10>font-weight: 300</p> <p font-600>font-weight: 600</p> <p font-bold>font-weight: bold</p> </div> </div> <div flex p-20 ml-35 rounded-5 bg-white> <div text-20 font-600>颜色:</div> <div ml-15 p-10 pl-30 pr-30 rounded-5> <p color="#881337">color: #881337</p> <p c-pink-500>color: #ec4899</p> <p bg="pink" mt-10>background: pink</p> <p bg="#2563eb" mt-10>background: #2563eb</p> </div> </div> </div> </div> </template> <script setup></script> 重新启动看到以下页面就证明集成成功了,另外 src/components/HelloWorld.vue 文件可以直接删除了
0
0
0
浏览量2012
前端码农

从0到1,带你搭建Vite+Vue3+Unocss+Pinia+Naive UI后台(三)UI组件篇

UI组件篇本篇主要介绍如何集成Naive UI,实现Naive UI的按需引入、主题色修改,以及基础组件的配置使得使用Naive UI更加的得心应手安装并按需引入Naive UINaive UI 官方文档一、安装Naive UI、unplugin-vue-componentspnpm i naive-ui unplugin-vue-components -D二、 修改 build/plugin/index.jsbuild/plugin/index.jsimport vue from '@vitejs/plugin-vue' /** * * 扩展setup插件,支持在script标签中使用name属性 * usage: <script setup name="MyComp"></script> */ import VueSetupExtend from 'vite-plugin-vue-setup-extend' // rollup打包分析插件 import visualizer from 'rollup-plugin-visualizer' import { configHtmlPlugin } from './html' import { unocss } from './unocss' /** * * 组件库按需引入插件 * usage: 直接使用组件,无需在任何地方导入组件 */ import Components from 'unplugin-vue-components/vite' import { NaiveUiResolver } from 'unplugin-vue-components/resolvers' export function createVitePlugins(viteEnv, isBuild) { const plugins = [ vue(), VueSetupExtend(), configHtmlPlugin(viteEnv, isBuild), unocss(), Components({ resolvers: [NaiveUiResolver()], }), ] if (isBuild) { plugins.push( visualizer({ open: true, gzipSize: true, brotliSize: true, }) ) } return plugins }具体文件改动请看下图三、 验证是否集成成功为方便验证,我们先处理下原来App.vue中的内容新建文件 src/views/test-page/unocss/index.vue把原来 App.vue 的内容剪切到新文件中我们从Naive UI官方文档拷贝一段按钮组件的示例代码到 App.vue中App.vue<template> <n-space m-50> <n-button>Default</n-button> <n-button type="tertiary"> Tertiary </n-button> <n-button type="primary"> Primary </n-button> <n-button type="info"> Info </n-button> <n-button type="success"> Success </n-button> <n-button type="warning"> Warning </n-button> <n-button type="error"> Error </n-button> </n-space> </template>看到下图页面就证明集成成功了配置方式十分简单,配置完我们就可以无需引入直接使用Naive UI的任意组件了(不过有几个基础组件使用会稍微麻烦点,下面会讲到)修改主题色Naive UI 提供了多种调整主题色的方式,下面介绍其中一种方式,有其他需求的可以参看 调整主题 - Naive UI新建文件 src/components/AppProvider/index.vuesrc/components/AppProvider/index.vue<template> <n-config-provider :theme-overrides="themeOverrides"> <slot></slot> </n-config-provider> </template> <script setup> const themeOverrides = { common: { primaryColor: '#316C72FF', primaryColorHover: '#316C72E3', primaryColorPressed: '#2B4C59FF', primaryColorSuppl: '#316C7263', }, } </script> 修改文件 App.vueApp.vue<template> <AppProvider> <n-space m-50> <n-button>Default</n-button> <n-button type="tertiary"> Tertiary </n-button> <n-button type="primary"> Primary </n-button> <n-button type="info"> Info </n-button> <n-button type="success"> Success </n-button> <n-button type="warning"> Warning </n-button> <n-button type="error"> Error </n-button> </n-space> </AppProvider> </template> <script setup> import AppProvider from '@/components/AppProvider/index.vue' </script> 看到下图效果发现我们修改的主题生效了基础组件配置上文有提到有部分基础组件使用起来有点麻烦,比如 Message 组件,要使用Message需要像上图一样在外面套一层 n-message-provider , 并且不能在setup外使用,十分的不便,当然官方也提供了特殊的解决方式修改 src/components/AppProvider/index.vuesrc/components/AppProvider/index.vue<template> <n-config-provider :theme-overrides="themeOverrides"> <n-loading-bar-provider> <n-dialog-provider> <n-notification-provider> <n-message-provider> <slot></slot> <NaiveProviderContent /> </n-message-provider> </n-notification-provider> </n-dialog-provider> </n-loading-bar-provider> </n-config-provider> </template> <script setup> import { defineComponent, h } from 'vue' import { useLoadingBar, useDialog, useMessage, useNotification } from 'naive-ui' const themeOverrides = { common: { primaryColor: '#316C72FF', primaryColorHover: '#316C72E3', primaryColorPressed: '#2B4C59FF', primaryColorSuppl: '#316C7263', }, } // 挂载naive组件的方法至window, 以便在全局使用 function setupNaiveTools() { window.$loadingBar = useLoadingBar() window.$dialog = useDialog() window.$message = useMessage() window.$notification = useNotification() } const NaiveProviderContent = defineComponent({ setup() { setupNaiveTools() }, render() { return h('div') }, }) </script> 修改 App.vue 验证是否生效App.vue... <script setup> import { onMounted } from 'vue' import AppProvider from '@/components/AppProvider/index.vue' onMounted(() => { $loadingBar.start() setTimeout(() => { $loadingBar.finish() $message.success('加载完成,Perfect~') }, 500) }) </script> 看到下图效果就没问题了总结Naive UI 在Vue3组件库中算是相当优秀的了,组件完整、主题可调,并且所有组件都可以 treeshaking,整体风格清爽,也得到了尤大赞扬和肯定,目前github 9.2k star,作为一款没有Vue2用户积淀的UI组件库已经很不错了
0
0
0
浏览量2013

履历