使用的协议是 HTTP 还是 HTTPS,为什么没用 HTTPS?
在前端开发中,有些网站使用 HTTP 协议,有些使用 HTTPS 协议。
使用 HTTP 协议的情况可能是因为网站对安全性的要求不是极高,或者处于开发的早期阶段,还没有来得及配置 HTTPS。HTTP 协议相对简单直接,它是超文本传输协议,用于在 Web 浏览器和服务器之间传输数据。例如一些纯信息展示的内部网站,没有涉及用户敏感信息传输,如一些公司内部的知识库网页,仅用于员工查阅文档,这种情况下使用 HTTP 可能就足够了。
没有使用 HTTPS 可能是因为成本方面的考虑。获取和维护 SSL/TLS 证书(用于实现 HTTPS)是需要一定费用的,对于小型的或者非商业性质的网站来说可能是一笔不小的开支。同时,配置 HTTPS 服务器需要一定的技术知识和资源,包括服务器的配置和维护,这也增加了使用它的难度。
另外,从性能角度看,在一些对性能要求极高且安全风险较低的场景下,如一些简单的内容分发网络(CDN)节点,提供公共的静态资源下载,开发者可能会认为 HTTP 的性能开销更小,所以暂时没有采用 HTTPS。
为什么 HTTPS 更安全?
HTTPS(超文本传输安全协议)比 HTTP 更安全主要是因为它使用了 SSL/TLS(安全套接层 / 传输层安全)加密协议。
当使用 HTTPS 时,客户端和服务器之间的通信数据会被加密。例如,当用户在浏览器中输入银行网站的网址并登录账号进行转账操作时,用户名、密码以及转账金额等敏感信息会通过加密的通道传输。如果是 HTTP 协议,这些数据是以明文形式传输的,就像信件没有装在信封里直接传递一样,在传输过程中很容易被中间人截获。而在 HTTPS 下,这些数据就像被装进了加密的信封,只有收件人(服务器)有钥匙(密钥)打开信封读取内容。
SSL/TLS 协议还提供了身份验证机制。服务器会向客户端提供数字证书,这个证书由受信任的证书颁发机构(CA)颁发。浏览器会验证这个证书的有效性,确保用户连接的是真正的目标服务器,而不是被伪装的服务器。比如,当用户访问一个知名购物网站时,浏览器会检查该网站的证书,确认是真正的购物网站而不是钓鱼网站。这样就防止了中间人攻击,攻击者很难伪装成合法的服务器来窃取用户信息。
此外,HTTPS 的加密通信可以防止数据在传输过程中被篡改。数据在传输过程中会有一些校验机制,一旦数据被修改,接收方(浏览器或者服务器)就能检测出来,保证了数据的完整性。
HTTP 下面一层的协议(TCP)是什么?
TCP(传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。它位于 HTTP 协议之下,主要作用是为上层应用(如 HTTP)提供可靠的通信服务。
从连接建立的角度看,TCP 是面向连接的。在通信双方开始传输数据之前,必须先建立一个连接。就好像打电话一样,双方需要先拨通对方的电话,建立起通话连接,这个过程通过 TCP 的三次握手来完成。例如,当浏览器请求一个网页时,它会和服务器之间通过三次握手建立 TCP 连接。首先,客户端(浏览器)向服务器发送一个带有 SYN(同步序列号)标志的数据包,表示想要建立连接。服务器收到后,会返回一个带有 SYN 和 ACK(确认)标志的数据包,表示收到了请求并且同意建立连接。最后,客户端再发送一个带有 ACK 标志的数据包,表示确认收到服务器的同意连接信息,这样连接就建立起来了。
TCP 的可靠性体现在它有很多机制来保证数据的正确传输。它会对发送的数据进行编号,接收方收到数据后会通过 ACK 来确认收到的数据序号。如果发送方在一定时间内没有收到 ACK,就会重新发送数据。这种机制可以确保数据不会丢失。而且,TCP 会对收到的数据进行排序,因为在网络传输过程中,数据包可能会乱序到达。比如发送了数据包 1、2、3,接收方可能先收到 3,再收到 1 和 2,TCP 会将这些数据包按照正确的顺序重组后再交给上层应用。
TCP 还是基于字节流的,这意味着它把传输的数据看作是无结构的字节序列。这和 UDP(用户数据报协议)不同,UDP 是基于数据报的。对于 TCP 来说,它并不关心上层应用数据的格式,只是负责将字节流可靠地从一端传输到另一端。例如,当 HTTP 协议要传输一个网页的 HTML 文件内容时,TCP 会把这些内容看作是字节流,按照自己的规则进行传输。
TCP 与 UDP 的区别是什么?
TCP 和 UDP 都是传输层协议,但它们有很多不同之处。
首先,TCP 是面向连接的,而 UDP 是无连接的。如前面所说,TCP 在通信前要通过三次握手建立连接,就像建立了一条通信管道。在数据传输结束后,还会通过四次挥手来断开连接。而 UDP 就像发送短信一样,不需要事先建立连接。发送方直接把数据报发送出去,不管接收方是否准备好接收。例如,在一些实时性要求很高的场景下,像视频直播,采用 UDP 协议,主播端直接发送视频数据报,观众端只要能够接收就可以播放,不需要像 TCP 那样先建立复杂的连接。
TCP 是可靠的传输协议,UDP 是不可靠的。TCP 通过序列号、确认号、重传机制等来保证数据能够正确、完整地到达目的地。如果数据丢失或者损坏,TCP 会自动重传。UDP 则没有这些机制,它发送数据报后不会关心数据是否被正确接收。比如在网络游戏中,如果使用 UDP 协议,当玩家发送一个操作指令(如角色移动)的数据报,即使这个数据报丢失了,UDP 也不会重传,游戏可能会出现角色卡顿等情况;而如果是 TCP 协议,就会尽量保证这个指令被正确接收。
从传输效率来看,UDP 通常比 TCP 效率高。因为 TCP 要建立和维护连接,并且有很多可靠性保障机制,这些都会带来一定的开销。UDP 没有这些额外的开销,它只是简单地把数据报发送出去。例如,在一些对实时性要求极高的场景,如语音通话,稍微丢失一些数据(可能导致声音有一点杂音)比因为重传等机制导致的延迟更能被接受,所以 UDP 更适合这种场景。
在数据传输方式上,TCP 是基于字节流的,UDP 是基于数据报的。TCP 把传输的数据看作是连续的字节流,它会对字节流进行分割和重组。UDP 则是按照数据报来发送和接收,每个数据报都是独立的单元,接收方收到的数据报和发送方发送的数据报大小基本是一致的。比如,UDP 发送一个固定大小的数据报,接收方收到的也是这个大小的数据报(除非数据报丢失或者损坏),而 TCP 发送的字节流在接收方可能会根据网络情况等因素被分割成不同大小的部分进行接收,然后再重组。
什么是面向连接?
面向连接是一种通信方式。在网络通信中,面向连接的协议(如 TCP)在数据传输之前需要先建立一个逻辑连接。
以打电话为例来理解面向连接。当你要给一个人打电话时,首先要拨号,对方接听后,就建立起了一个通话连接。这个连接就像是一条专用的通信通道,在通话过程中,数据(声音)通过这个通道进行传输。在网络通信中,面向连接的过程涉及到连接的建立、维护和拆除。
在建立连接阶段,对于 TCP 协议来说,就是前面提到的三次握手过程。这个过程确保通信双方都知道彼此的存在并且准备好进行数据传输。就好像两个人在打电话前,通过拨号和接听确认双方都在电话旁边并且可以开始交流。
在连接建立好之后,数据传输过程中,面向连接的协议会维护这个连接。它会确保数据按照正确的顺序传输,并且能够正确地到达目的地。比如,在网络中,如果数据包在传输过程中丢失或者出现错误,面向连接的协议会采取措施进行纠正,如重传丢失的数据包或者重新排序乱序的数据包。这就好比在电话通话过程中,如果声音信号受到干扰,电话系统会尽力去恢复清晰的声音。
最后,当数据传输完成后,需要拆除连接,这就像打完电话后要挂断一样。在 TCP 协议中,是通过四次挥手来完成连接的拆除。这样可以释放系统资源,为下一次通信做好准备。面向连接的方式保证了通信的可靠性和有序性,适用于对数据准确性和完整性要求较高的应用场景,如文件传输、网页浏览等。在网页浏览中,浏览器和服务器之间通过 TCP 建立面向连接的通信,以确保 HTML 文件、图片等各种资源能够准确无误地传输到浏览器,让用户能够正确地浏览网页。
为什么 TCP 连接不需要四次握手?
TCP 采用三次握手而不是四次握手主要是出于效率和可靠性的综合考虑。
在三次握手过程中,首先客户端发送一个带有 SYN 标志的数据包,表示想要建立连接。这个数据包包含了客户端初始的序列号,这个序列号用于后续数据传输的顺序编号。服务器收到这个 SYN 包后,回复一个 SYN - ACK 包,这个包一方面确认收到了客户端的 SYN 请求,同时也发送自己的 SYN 请求,包含服务器自己的初始序列号。最后客户端发送一个 ACK 包来确认收到服务器的 SYN 请求。
如果采用四次握手,可能会出现不必要的复杂情况。假设在现有三次握手基础上增加一次握手,比如在客户端发送 ACK 之后,服务器再发送一次确认。但实际上,当客户端发送 ACK 后,从客户端角度看,连接已经可以用于发送数据了,因为它已经完成了对服务器连接请求的确认。对于服务器而言,收到客户端的 ACK 后,也能够确认连接已经建立。如果再增加一次握手,会增加网络延迟和额外的资源消耗,而且没有给连接建立过程增加实质性的可靠性保证。
从另一个角度看,三次握手已经能够有效防止旧的重复连接请求导致的混乱。例如,假设存在一个过期的 SYN 包在网络中延迟后到达服务器,服务器回复 SYN - ACK,但是客户端由于已经建立了新的有效连接,会忽略这个过期的 SYN - ACK,因为它没有对应的发送 SYN 请求。如果是四次握手,这种情况可能会导致更复杂的错误判断和资源浪费。
关系型数据库的三大范式,说出自己的理解。
第一范式(1NF)要求数据库表的每一列都是不可分割的原子数据项。这意味着表中的每个属性都应该是最基本的、不可再细分的单元。例如,在一个学生信息表中,如果有一个 “联系方式” 列,里面同时包含了电话号码和电子邮箱,这就不符合第一范式。应该将其拆分为 “电话号码” 列和 “电子邮箱” 列,这样可以保证数据的简洁性和准确性。遵循第一范式有助于数据的存储和维护,方便对每一个属性进行单独的操作和查询。
第二范式(2NF)是在满足第一范式的基础上,要求非主属性完全依赖于主键。比如有一个订单详情表,主键是 “订单编号”,表中有 “商品编号”“商品名称”“商品价格”“订单日期” 等属性。“商品名称” 和 “商品价格” 是完全依赖于 “商品编号” 的,而 “订单日期” 是完全依赖于 “订单编号” 的。如果 “商品名称” 只依赖于 “商品编号”,而不依赖于 “订单编号”,那么这个表就不符合第二范式。这样的要求可以减少数据冗余,因为如果一个非主属性不完全依赖于主键,可能会导致在多条记录中重复存储相同的数据,浪费存储空间并且可能导致数据不一致。
第三范式(3NF)是在满足第二范式的基础上,要求非主属性之间不存在传递依赖。例如,有一个员工信息表,包含 “员工编号”(主键)、“部门编号” 和 “部门名称”。“部门名称” 通过 “部门编号” 依赖于 “员工编号”,这就存在传递依赖,不符合第三范式。应该将 “部门名称” 单独放在一个部门表中,通过 “部门编号” 关联。遵循第三范式可以进一步减少数据冗余,并且可以提高数据的更新效率。当需要更新某个非主属性时,如果存在传递依赖,可能需要更新多个地方,容易出现数据不一致的情况。而在第三范式下,更新可以更集中在相关的表中,降低出错的概率。
进程和线程的区别是什么?
进程是计算机中程序的一次执行过程,它是一个独立的资源分配单元。每个进程都有自己独立的内存空间、代码段、数据段和操作系统分配的其他资源,如文件描述符等。例如,当打开一个文字处理软件和一个浏览器软件时,它们就是两个不同的进程。文字处理软件的进程有自己的内存空间来存储文档内容、格式等数据,浏览器进程也有自己独立的空间来存储网页内容、历史记录等。
线程是进程内部的一个执行单元,它共享进程的资源。一个进程可以包含多个线程。比如在一个浏览器进程中,可能有一个线程负责页面渲染,另一个线程负责处理用户的交互事件(如点击链接、滚动页面等)。这些线程共享浏览器进程的内存空间和其他资源。
从资源占用角度看,进程占用的资源相对较多,因为它有独立的资源分配。而线程由于共享进程资源,所以相对来说资源占用较少。当创建一个新的进程时,操作系统需要为其分配独立的内存空间等资源,这是一个比较复杂的过程。而创建一个新的线程相对简单,因为它可以直接使用进程已经分配好的资源。
在通信方面,进程间通信相对复杂,因为它们的内存空间是相互独立的。通常需要使用一些特殊的通信机制,如管道、消息队列、共享内存等。线程间通信相对简单,因为它们共享进程的内存空间,所以可以通过共享变量等方式直接进行通信。不过,这种共享也带来了一定的风险,比如需要注意线程安全问题,避免多个线程同时访问和修改同一个变量导致数据不一致。
在调度方面,进程是操作系统进行资源分配和调度的基本单位。操作系统会根据进程的优先级、资源需求等因素来分配 CPU 时间。线程是在进程内部被调度的,一个进程中的多个线程会共享进程所分配到的 CPU 时间。
CPU 多核的时候可以执行多个进程吗?
在 CPU 多核的情况下是可以执行多个进程的。
每个 CPU 核心都可以独立地执行一个进程。例如,一个四核 CPU 就可以同时处理四个不同的进程。当有多个进程处于就绪状态时,操作系统的调度程序会将这些进程分配到不同的 CPU 核心上执行。这就好比有一个工厂有多个车间(CPU 核心),每个车间都可以独立地加工一个产品(执行一个进程)。
不过,进程的执行还受到很多因素的影响。比如,进程可能会因为等待某些资源(如等待从硬盘读取数据、等待网络响应等)而进入阻塞状态。即使 CPU 有多个核心,当一个进程进入阻塞状态时,它所占用的 CPU 核心会被释放,然后操作系统可以将其他就绪进程调度到这个核心上执行。
同时,操作系统对于多核 CPU 的调度策略也有多种。有些调度策略是基于时间片轮转的,每个进程在每个 CPU 核心上会被分配一定的时间片来执行,当时间片用完后,可能会被暂停,然后另一个进程被调度到这个核心上。还有一些调度策略会考虑进程的优先级等因素,高优先级的进程可能会优先被分配到 CPU 核心上执行。
另外,进程之间可能会存在同步和互斥的需求。例如,多个进程可能会同时访问一个共享资源(如共享内存中的数据),这时候就需要一些机制来保证数据的一致性和正确性。在多核 CPU 环境下,这种同步和互斥的机制更加复杂,因为多个进程可能在不同的 CPU 核心上同时运行,操作系统需要通过锁、信号量等机制来协调它们的执行。
小程序和 Vue 开发,你觉得两者之间有什么不同?
小程序开发和 Vue 开发有许多不同之处。
从开发环境角度看,小程序有自己特定的开发环境。例如微信小程序,需要使用微信开发者工具进行开发。这个工具提供了专门用于小程序开发的模板、调试环境等。而 Vue 开发可以使用多种文本编辑器或集成开发环境(IDE),如 WebStorm、Visual Studio Code 等。Vue 开发主要是基于 Web 标准的开发环境,开发的是网页应用。
在组件化方面,两者都有组件化的思想,但具体实现有所不同。小程序有自己的一套组件规范。例如微信小程序提供了视图容器、基础内容组件、表单组件等多种组件。这些组件有特定的属性和事件,开发者需要按照小程序的规范来使用。Vue 也有组件化的开发方式,通过创建.vue 文件来定义组件,包括模板、脚本和样式部分。Vue 组件可以更灵活地进行数据传递和通信,通过 props 接收父组件传递的数据,通过事件来和父组件进行交互。
在框架特性上,小程序框架有其特定的限制和优势。小程序通常会有比较严格的性能优化要求,因为它运行在移动设备上,资源相对有限。小程序框架会对页面的渲染、数据更新等进行优化,以提高用户体验。例如,微信小程序采用了双线程模型,一个线程用于渲染页面,另一个线程用于处理逻辑。Vue 则更注重数据驱动和响应式原理。当数据发生变化时,Vue 会自动更新与之绑定的 DOM 元素,这种响应式更新可以让开发者更方便地构建交互性强的应用。
在生态系统方面,Vue 有丰富的插件和第三方库。开发者可以利用这些插件来实现各种功能,如路由管理(Vue Router)、状态管理(Vuex)等。小程序也有自己的插件生态,但相对来说比较封闭,主要是针对小程序自身的特点开发的插件,如微信小程序的支付插件、地图插件等,这些插件主要是为了方便在小程序内部实现特定的功能。
在发布和部署方面,小程序的发布需要经过平台的审核。例如微信小程序,需要提交代码到微信平台进行审核,审核通过后才能发布给用户使用。而 Vue 开发的应用,只要部署到服务器上,通过正确的域名访问就可以使用,相对来说更加自由。
你做的小程序项目中,你遇到了什么难点,你是怎么解决的?
在小程序开发过程中,遇到了不少有挑战性的问题。
一个比较突出的难点是小程序的性能优化。由于小程序运行在移动设备上,资源有限,在项目中有较多页面交互和数据加载的情况下,很容易出现页面卡顿。例如,在一个商品展示小程序中,当用户快速滑动商品列表时,图片加载和页面渲染出现延迟。为了解决这个问题,首先对图片进行了懒加载处理。只有当图片进入可视区域时才进行加载,这样就避免了一次性加载大量图片导致的性能问题。同时,对于页面渲染,尽量减少不必要的 DOM 操作,通过数据绑定来更新页面,而不是频繁地操作 DOM 元素。
另一个难点是小程序的兼容性。不同型号的手机以及不同版本的微信小程序可能会出现样式或者功能不一致的情况。比如在某些旧型号手机上,小程序的某些动画效果无法正常显示。针对这个问题,在开发过程中进行了大量的真机测试。记录下在不同设备上出现的问题,然后通过使用媒体查询和条件编译来处理样式兼容性。对于功能兼容性,采用了渐进式增强的策略,确保核心功能在所有设备上都能正常运行,对于一些高级功能,在不支持的设备上提供替代方案或者友好的提示。
还有就是小程序的接口调用问题。在和后端服务器进行数据交互时,有时候会出现网络延迟或者接口返回数据格式不符合预期的情况。例如,在获取用户订单数据的接口中,偶尔会出现数据丢失或者数据格式错误。解决方法是增加了数据校验和错误处理机制。在前端接收到接口数据后,先进行格式和完整性的校验,对于不符合要求的数据,及时向用户反馈错误信息,同时重新请求数据或者提示用户检查网络连接。
Vue 的生命周期,在哪一步实现数据挂载的?
在 Vue 的生命周期中,数据挂载主要是在mounted
阶段实现的。
在组件实例创建之后,首先会经过beforeCreate
阶段。这个阶段,组件的实例已经被初始化,但是数据观测(data observer)和事件机制还没有被配置好,所以此时无法访问组件的数据和方法。
接着进入created
阶段,在这个阶段,Vue 已经完成了数据观测,属性和方法的运算,事件回调也已经配置好了。可以在这个阶段访问和修改组件的数据,不过此时 DOM 还没有被挂载,所以如果尝试访问 DOM 元素是获取不到的。
然后就到了mounted
阶段,这是数据挂载的关键阶段。当这个阶段执行时,Vue 已经将编译好的模板挂载到了真实的 DOM 元素上。这意味着在mounted
阶段,可以直接操作 DOM 元素,并且可以确保数据已经和 DOM 进行了绑定。例如,如果在组件中有一个数据属性message
,并且在模板中有一个<p>{{message}}</p>
这样的元素,在mounted
阶段,message
的值已经正确地渲染到了<p>
标签中。而且,在这个阶段可以进行一些需要 DOM 操作的初始化工作,比如获取某个 DOM 元素的高度、宽度等属性,或者对 DOM 元素添加一些第三方插件的初始化操作。
在mounted
之后,还有其他的生命周期阶段,如beforeUpdate
、updated
、beforeDestroy
和destroyed
。这些阶段主要用于处理数据更新和组件销毁相关的操作。
看你框架在用 Vue,来聊聊 MVC 和 MVVM?
MVC(Model - View - Controller)是一种软件架构模式。
在 MVC 中,Model 代表数据模型,它负责处理数据的存储、检索等操作。例如,在一个简单的用户管理系统中,Model 可能包含用户信息的数据结构和对这些数据进行增删改查的方法。View 是视图,它主要负责数据的展示,通常是用户界面部分。比如在网页应用中,View 可能是 HTML 页面,它会显示用户列表等信息。Controller 则是控制器,它起到了连接 Model 和 View 的作用。当用户在 View 中进行操作(如点击一个按钮来添加新用户),这个操作会被发送到 Controller,Controller 会根据这个操作调用 Model 中的相应方法来处理数据,然后将处理后的结果更新到 View 上。这种模式的优点是职责分离比较明确,Model 和 View 之间的耦合度相对较低,有利于代码的维护和扩展。不过,在复杂的应用中,Controller 可能会变得非常臃肿,因为它要处理大量的用户操作和数据更新逻辑。
MVVM(Model - View - ViewModel)是另一种架构模式,Vue 就是基于 MVVM 的。在 MVVM 中,Model 同样是数据模型,和 MVC 中的类似,负责数据的存储和操作。View 是视图,也是用于展示数据的用户界面。ViewModel 是 MVVM 的核心部分,它是一个数据绑定器,用于连接 Model 和 View。在 Vue 中,ViewModel 的角色由 Vue 实例来扮演。例如,在一个 Vue 组件中,定义的 data 属性就是 Model 的一部分,模板部分就是 View。当 data 中的数据发生变化时,Vue 会自动更新 View,这是通过数据绑定实现的。同样,当用户在 View 中进行操作(如在表单中输入数据),这些操作会通过双向绑定更新 Model 中的数据。MVVM 的优点是数据绑定更加自动化,减少了大量手动操作 DOM 更新数据的代码,提高了开发效率,并且使得代码结构更加清晰,易于理解和维护。
vue 双向绑定原理,提问观察者,订阅者两个模式有啥不同?
在 Vue 的双向绑定原理中,观察者模式和订阅者模式有一些区别。
观察者模式主要涉及到目标对象和观察者对象。目标对象(被观察的对象)维护着一个观察者列表。当目标对象的状态发生变化时,它会通知所有的观察者。例如,在 Vue 的数据对象中,数据属性可以看作是目标对象。当一个数据属性的值发生变化时,它会通知所有与之相关的观察者(如模板中的 DOM 元素)。这些观察者会根据变化来更新自己的状态。观察者模式强调的是目标对象和观察者之间的一对多关系,一个目标对象可以有多个观察者,并且是由目标对象来主动通知观察者状态的改变。
订阅者模式则有发布者和订阅者两个角色。订阅者可以向发布者订阅感兴趣的事件或者消息。发布者维护着一个订阅者列表,当有相应的事件发生时,发布者会将消息发送给所有订阅了该事件的订阅者。在 Vue 的双向绑定中,数据的更新可以看作是发布者发布消息,而 DOM 更新或者其他对数据变化做出响应的操作可以看作是订阅者接收消息。订阅者模式更强调事件和消息的发布与订阅,订阅者可以订阅多个发布者的不同事件,发布者也可以有多个订阅者。
在 Vue 的双向绑定实现中,结合了这两种模式的一些特点。数据对象可以看作是一个被观察的发布者,当数据变化时,它会通知所有订阅了数据变化这个事件的订阅者(如 DOM 更新函数、计算属性等)。同时,DOM 元素等可以看作是观察者,它们会观察数据对象的状态变化,并且在收到通知后做出相应的更新操作。
谈谈对 ES6 的理解呢,新增了什么内容?
ES6(ECMAScript 2015)是 JavaScript 语言的一个重要版本,它带来了许多新的特性和语法糖,极大地提升了 JavaScript 的开发效率和代码质量。
在变量声明方面,ES6 引入了let
和const
。let
用于声明块级变量,它解决了var
变量提升带来的一些问题。例如,使用var
声明变量时,变量会被提升到函数顶部,这可能会导致一些意外的变量覆盖。而let
变量只在它所在的块级作用域内有效,比如在一个for
循环中使用let
声明的变量,在每次循环迭代中都是一个独立的变量。const
用于声明常量,一旦声明,其值不能被修改。这对于定义一些不会改变的值(如数学常数、配置信息等)非常有用,可以避免不小心修改了这些重要的值。
ES6 还引入了箭头函数。箭头函数是一种更简洁的函数定义方式。例如,传统的函数定义可能是function add(a, b) { return a + b; }
,而使用箭头函数可以写成const add = (a, b) => a + b
。箭头函数的this
指向和普通函数不同,它会继承外层函数的this
,这在处理回调函数等场景中可以避免this
指向错误的问题。
模板字符串也是 ES6 的一个重要特性。它允许使用反引号()来定义字符串,并且可以在字符串中嵌入变量和表达式。例如,
const name = "John"; console.log(Hello, ${name}!
);`。这种方式比传统的字符串拼接更加清晰和方便,尤其是在构建复杂的字符串(如 HTML 模板)时非常有用。
在对象和数组的操作方面,ES6 提供了新的方法。如对象的解构赋值,const {a, b} = {a: 1, b: 2};
可以方便地从对象中提取属性值。数组的扩展运算符(...
)可以用于数组的拼接、复制等操作。例如,const arr1 = [1, 2]; const arr2 = [3, 4]; const newArr = [...arr1,...arr2];
就可以将两个数组合并为一个新数组。
ES6 还引入了类(class
)的概念,使得 JavaScript 在面向对象编程方面更加规范。虽然 JavaScript 的类本质上还是基于原型继承,但class
语法让代码看起来更像传统的面向对象语言。例如,可以定义一个简单的类class Person { constructor(name) { this.name = name; } sayHello() { console.log(
Hello, my name is ${this.name}); } }
,这种方式比以前通过函数和原型来定义对象更加直观。
刚才提到了 new,能说说 new 一个对象经历了什么吗?
当使用new
关键字来创建一个对象时,会发生一系列的操作。
首先,会创建一个新的空对象。这个空对象的类型是由构造函数来确定的。例如,当使用new Person()
(假设Person
是一个构造函数)时,会创建一个新的对象,这个对象将作为Person
类的一个实例。
接着,会将这个新创建的空对象的__proto__
属性(在一些浏览器环境下)或者[[Prototype]]
属性(在标准的 JavaScript 规范中)设置为构造函数的原型对象。这一步非常关键,因为它建立了对象和其构造函数原型之间的联系,使得对象可以访问原型上的方法和属性。比如,如果Person
构造函数的原型对象上有一个sayHello
方法,那么通过new Person()
创建的对象就可以访问这个方法。
然后,构造函数会被调用,并且将新创建的对象作为this
的值传递进去。在构造函数内部,可以通过this
来初始化对象的属性。例如,在Person
构造函数内部可以有this.name = 'John';
这样的语句,来为新创建的对象添加一个name
属性。
最后,当构造函数执行完毕后,如果构造函数没有返回其他对象(返回值不是一个对象或者函数),那么new
操作会自动返回这个新创建的对象。如果构造函数返回了一个对象或者函数,那么new
操作就会返回这个被返回的对象或者函数,而不是之前创建的那个新对象。
这种new
操作的机制使得我们可以方便地创建多个具有相同结构和初始属性的对象,并且可以通过原型链来共享方法和属性,从而提高代码的复用性和效率。同时,理解new
的工作原理也有助于更好地理解 JavaScript 的面向对象编程。
聊聊闭包。怎么创建一个闭包呢?闭包能做什么?
闭包是指有权访问另一个函数作用域中变量的函数。
创建一个闭包很简单,在一个函数内部定义另一个函数,并且内部函数引用了外部函数的变量,就形成了闭包。例如,有一个函数outer
,在它内部定义了一个变量count
和一个函数inner
,inner
函数可以访问count
变量,像这样:
function outer() {
let count = 0;
function inner() {
count++;
console.log(count);
}
return inner;
}
在这个例子中,当outer
函数被调用时,它返回inner
函数。此时,inner
函数就形成了一个闭包,因为它可以访问outer
函数内部的count
变量,即使outer
函数已经执行完毕,count
变量依然存在于内存中,因为inner
函数仍然引用着它。
闭包有很多用途。一是可以实现数据隐藏和封装。由于闭包内部的变量只能通过闭包函数来访问,所以可以将一些变量和操作封装在闭包内部,防止外部代码的干扰。例如,在一个计数器的闭包中,外部代码无法直接修改内部的计数变量,只能通过闭包提供的方法来操作。
闭包还可以用于缓存数据。比如有一个函数,它需要频繁地计算某个复杂的数学公式,但是这个公式的某些参数是固定的。可以使用闭包将这些固定参数保存起来,下次调用时如果参数不变,就可以直接使用缓存的结果,提高了函数的执行效率。
另外,在异步编程中,闭包也非常有用。例如,在一个定时器回调函数或者网络请求回调函数中,需要访问外部的变量,闭包就可以保证这些变量在回调函数执行时仍然可用。
看你的项目是用 Vue 做的,谈谈你对 mvvm 的理解。
MVVM(Model - View - ViewModel)是一种设计模式,在 Vue 项目中体现得非常明显。
Model 代表数据模型,在 Vue 中通常是组件的data
属性或者从外部获取的数据。这些数据是应用的核心部分,包含了所有需要展示和处理的信息。例如,在一个电商应用的商品列表组件中,Model 可能包含商品的名称、价格、库存等信息。
View 是视图层,在 Vue 中主要是模板部分,也就是用户最终看到的界面。它负责将数据以直观的方式呈现给用户。比如,商品列表组件的视图就是展示商品名称、价格等信息的 HTML 结构,用户可以通过这个视图来浏览商品信息。
ViewModel 是连接 Model 和 View 的桥梁,在 Vue 中,Vue 实例或者组件实例扮演了这个角色。它的主要功能是数据绑定和事件处理。通过数据绑定,ViewModel 能够将 Model 中的数据自动反映到 View 上。例如,当 Model 中的商品价格发生变化时,Vue 会自动更新 View 中对应的价格显示部分。同时,ViewModel 也会处理 View 中的事件,比如用户点击商品的 “加入购物车” 按钮,这个事件会被 ViewModel 捕获,然后它会调用 Model 中的方法来处理购物车数据的更新。
这种模式的优势在于它使得数据和视图之间的关系更加紧密和自动化。开发者不需要手动操作 DOM 来更新视图,减少了大量繁琐的代码。同时,由于数据和视图的分离,代码的维护和扩展变得更加容易。如果需要修改数据的格式或者添加新的数据,只需要在 Model 中进行操作,而视图会自动更新。如果要改变视图的布局或者样式,也不会影响到数据的处理逻辑。
hash 和 history 是什么东西?他们的区别是什么?
在前端开发中,hash
和history
主要用于实现单页应用(SPA)中的路由功能。
hash
是 URL 中的一个部分,它以#
符号开头,位于 URL 的末尾。例如,http://example.com/#/home
中的#/home
就是hash
部分。在浏览器中,hash
的变化不会引起页面的重新加载。当hash
发生变化时,浏览器会触发hashchange
事件。利用这个特性,在单页应用中可以通过监听hashchange
事件来实现路由的切换。例如,在一个简单的单页应用中,当hash
从#/home
变为#/about
时,可以根据这个变化加载不同的视图组件,展示不同的页面内容。
history
是浏览器的历史记录对象,它记录了用户在浏览器中访问过的页面。在 HTML5 中,history
对象提供了新的 API,如pushState
和replaceState
,可以在不刷新页面的情况下改变浏览器的历史记录和当前的 URL。例如,使用history.pushState(null, null, '/new-url')
可以将当前的 URL 修改为/new-url
,并且不会引起页面的重新加载。同时,浏览器会触发popstate
事件,通过监听这个事件,可以实现类似hash
路由的功能,用于单页应用的路由切换。
它们的主要区别在于:
首先,hash
是 URL 的一部分,它比较直观地显示在地址栏中,而history
通过 API 来改变 URL,在某些情况下可以使 URL 看起来更整洁。例如,history
模式下的 URL 可能是http://example.com/home
,而hash
模式下是http://example.com/#/home
。
其次,hash
的兼容性更好,几乎所有的浏览器都支持hash
模式的路由。而history
模式在一些旧版本的浏览器中可能会存在兼容性问题,特别是在处理浏览器的前进和后退操作时。
另外,hash
模式下,服务器不需要对hash
部分进行特殊处理,因为浏览器不会将hash
部分发送给服务器。而history
模式下,当用户直接在浏览器中访问一个非根路径的 URL 时,服务器需要进行配置,将所有的请求都重定向到单页应用的入口文件,否则会出现 404 错误。
说说 Vue 的生命周期吧,越详细越好,我们是如何运用生命周期的呢?
Vue 的生命周期包含多个阶段,每个阶段都有其特定的作用。
首先是beforeCreate
阶段。在这个阶段,Vue 实例刚刚被创建,此时数据观测(data observer)和事件机制还没有被配置好。这意味着在这个阶段无法访问组件的数据和方法。它主要用于一些初始化的准备工作,比如加载一些外部的配置文件或者初始化一些全局变量,这些操作不依赖于组件的数据和方法。
接着是created
阶段。此时,Vue 已经完成了数据观测,属性和方法的运算,事件回调也已经配置好了。可以在这个阶段访问和修改组件的数据。不过,此时 DOM 还没有被挂载,所以如果尝试访问 DOM 元素是获取不到的。这个阶段常用于发起异步数据请求,因为可以在这里获取和处理数据,并且在数据获取完成后,通过数据绑定将数据渲染到 DOM 上,当 DOM 挂载后就可以正确显示数据。
然后是mounted
阶段。这是一个关键阶段,在这个阶段,Vue 已经将编译好的模板挂载到了真实的 DOM 元素上。可以直接操作 DOM 元素,并且可以确保数据已经和 DOM 进行了绑定。例如,可以在这个阶段初始化一些第三方插件,这些插件需要操作 DOM 元素。或者进行一些基于 DOM 的动画效果的初始化。
在组件运行过程中,会有beforeUpdate
阶段。当数据发生变化时,在更新 DOM 之前会触发这个阶段。这个阶段可以用于在 DOM 更新之前进行一些状态的保存或者进行一些特殊的数据处理,比如对数据变化进行记录或者验证。
updated
阶段是在 DOM 更新完成后触发。在这个阶段,可以对更新后的 DOM 进行操作,但是要注意避免无限循环更新。因为在这个阶段对数据的修改可能会再次触发更新。
当组件要被销毁时,会经历beforeDestroy
阶段。在这个阶段,实例仍然完全可用,可以进行一些清理工作,如清除定时器、取消订阅事件等。
最后是destroyed
阶段,此时组件已经被销毁,所有的事件监听器、子组件等都已经被移除。这个阶段可以用于释放一些占用的资源,比如关闭一些网络连接或者释放内存。
在实际应用中,可以根据不同的需求在各个生命周期阶段进行操作。例如,在mounted
阶段初始化图表插件,在beforeDestroy
阶段清理定时器,通过合理利用生命周期阶段,可以让组件的运行更加稳定和高效。
说说 vue 的双向绑定吧,怎么实现的呢,能说说代码吗?
Vue 的双向绑定是其核心特性之一,它主要通过数据劫持和发布 - 订阅模式来实现。
从数据劫持角度看,Vue 使用了Object.defineProperty()
方法来对数据进行劫持。当定义一个 Vue 组件的数据对象时,Vue 会遍历这个对象的所有属性。例如,有一个组件的data
属性如下:
data: {
message: 'Hello'
}
Vue 会对message
这个属性进行数据劫持。通过Object.defineProperty()
,可以在获取属性值和设置属性值的时候添加自定义的逻辑。在获取属性值时,返回真实的值;在设置属性值时,会触发一个更新的操作。这个更新操作会通知所有依赖这个属性的地方进行更新,这就涉及到发布 - 订阅模式。
在发布 - 订阅模式中,Vue 为每个组件实例都创建了一个Dep
(依赖)对象,它可以看作是一个消息中心。当一个数据属性被多个地方(如模板中的多个插值表达式或者计算属性)使用时,这些地方就会被添加为这个数据属性Dep
对象的订阅者。当数据属性发生变化时,Dep
对象就会作为发布者,通知所有的订阅者进行更新。
从代码层面简单理解,假设我们自己实现一个简单的双向绑定。首先是数据劫持部分:
function defineReactive(obj, key, val) {
const dep = new Dep();
Object.defineProperty(obj, key, {
get: function() {
dep.depend();
return val;
},
set: function(newVal) {
if (newVal!== val) {
val = newVal;
dep.notify();
}
}
});
}
在这个代码中,defineReactive
函数用于对对象的一个属性进行数据劫持。get
方法在属性被获取时调用,会添加依赖(depend
方法);set
方法在属性被设置时调用,会通知所有依赖更新(notify
方法)。
对于模板中的指令(如v - model
),它会在解析模板的时候,将元素的输入事件(如input
事件)和数据属性的更新关联起来。当用户在输入框输入内容时,会触发input
事件,这个事件会调用数据属性的set
方法,从而实现数据的双向更新。
你的项目都是自己开发的还是和别人合作开发的?
我的项目既有自己独立开发的部分,也有和别人合作开发的情况。
在独立开发项目时,从项目的规划、设计到具体的代码实现和测试都是由我自己完成。比如在开发一个小型的个人博客网站前端部分,我自己负责确定网站的整体布局,包括首页、文章列表页、文章详情页等各个页面的布局风格。我会根据设计稿来编写 HTML 结构,使用 CSS 进行样式设计,确保页面在不同设备上有良好的视觉效果。在功能实现方面,我会利用 JavaScript 来添加交互功能,如文章的点赞、评论功能等。同时,我会自己进行代码的测试,检查页面的兼容性,修复出现的问题。
在合作开发的项目中,团队成员之间会有明确的分工。以一个电商小程序开发项目为例,我可能主要负责商品展示和用户购物车相关的功能模块。在开发过程中,我会和负责后端接口开发的同事紧密合作。我们会先确定接口的规范,包括数据格式、请求方式等。我会根据接口文档来开发前端部分,在遇到接口数据不符合预期或者需要调整接口时,及时和后端同事沟通。同时,我也会和负责用户认证和支付模块的同事协作,确保购物车模块和其他模块之间的交互流畅。例如,在用户结算购物车商品时,要确保购物车数据准确无误地传递给支付模块,并且能够接收支付成功或失败的反馈,这些都需要团队成员之间密切沟通和配合来完成。
我看你项目中用了 mock,你的 mock 环境是怎么实现的?
在项目中实现 mock 环境主要是为了在后端接口尚未完成或者不方便使用真实后端接口的情况下,模拟数据请求和响应,以保证前端开发的顺利进行。
一种常见的实现方式是使用Mock.js
这个工具。首先,需要在项目中引入Mock.js
库。然后,可以通过定义模拟数据和接口来创建 mock 环境。例如,在一个用户管理系统的项目中,如果有一个获取用户列表的接口,我们可以这样模拟:
import Mock from'mockjs';
Mock.mock('/api/users', 'get', {
'data|10': [
{
'id|+1': 1,
'name': '@cname',
'age|18 - 60': 1,
'email': '@email'
}
]
});
在这个代码中,Mock.mock
函数的第一个参数是接口的路径(/api/users
),第二个参数是请求方式(get
),第三个参数是模拟的数据结构。这里使用了Mock.js
的语法,data|10
表示生成一个包含 10 个元素的数组,数组中的每个元素是一个用户对象。用户对象中的id|+1
表示id
属性从 1 开始自动递增,name
属性使用@cname
来生成一个中文名字,age
属性在 18 到 60 之间随机生成,email
属性使用@email
来生成一个邮箱地址。
在前端代码中,当使用axios
(或其他请求库)发送请求到/api/users
这个路径并且是get
请求方式时,就会返回模拟的数据,而不是真实的后端接口数据。这样就可以在前端独立地进行开发和测试,比如可以在页面上展示用户列表,测试列表的排序、筛选等功能,而不需要等待后端接口的完成。
另外,还可以根据项目的复杂程度和需求,将 mock 数据的定义和请求拦截等功能封装成一个模块,方便在不同的组件或者页面中使用。同时,在真实后端接口完成后,可以很方便地将 mock 环境切换为真实环境,只需要修改请求的地址或者配置即可。
我看你 wx.navigateTo 实现了跳转,你还知道哪些小程序路由跳转的方法,他们之间有什么区别?(面试官提示堆栈之间的区别)
在小程序中,除了wx.navigateTo
,还有wx.redirectTo
、wx.switchTab
和wx.reLaunch
这些路由跳转方法。
wx.navigateTo
用于保留当前页面,跳转到应用内的某个页面。它会将新页面推送到页面栈中,就像在一叠卡片上面再放一张新卡片。例如,当前在页面 A,使用wx.navigateTo
跳转到页面 B,此时页面栈中有两个页面,分别是 A 和 B。用户可以通过左上角的返回按钮(如果小程序有显示)从页面 B 返回到页面 A。这种方式适合在应用内进行页面之间的正常浏览,如从商品列表页跳转到商品详情页,用户查看完详情后可以方便地返回列表页。
wx.redirectTo
是关闭当前页面,跳转到应用内的某个页面。它不会将当前页面保留在页面栈中,而是直接用新页面替换当前页面。例如,在页面 A 使用wx.redirectTo
跳转到页面 B,此时页面栈中只有页面 B,用户无法通过返回按钮回到页面 A。这种方式适用于一些需要完全替换当前页面的场景,比如用户登录成功后,从登录页面跳转到主页面,登录页面就没有必要再保留在页面栈中。
wx.switchTab
用于跳转到小程序的 tabBar 页面。它的特点是如果目标页面是已经打开的 tabBar 页面,会直接切换过去,不会在页面栈中新增页面。例如,小程序底部有三个 tab,分别是首页、购物车和我的。如果当前在购物车页面,使用wx.switchTab
跳转到首页,页面栈不会有新的变化,只是将当前显示的页面切换为首页。这种方式是专门用于 tabBar 页面之间的切换,保证了 tabBar 页面的独立性和简洁性。
wx.reLaunch
是关闭所有页面,打开到应用内的某个页面。它会清空整个页面栈,然后打开新的页面。例如,在一个复杂的小程序流程中,用户完成了一系列操作后,需要跳转到一个全新的起始页面,就可以使用wx.reLaunch
。这种方式可以重新初始化整个小程序的页面栈,给用户一种全新的体验。
从输入 url 到浏览器输出页面,具体发生了什么操作?
当在浏览器中输入一个 URL 时,首先浏览器会进行 URL 解析。它会将 URL 分解为不同的部分,如协议(http 或 https)、域名、路径、查询参数等。例如,对于 URLhttps://example.com/path?param1=value1¶m2=value2
,浏览器会识别出协议是https
,域名是example.com
,路径是/path
,查询参数是param1=value1
和param2=value2
。
接着,浏览器会根据解析后的域名信息,通过 DNS(域名系统)查找对应的 IP 地址。浏览器会向本地 DNS 服务器发送请求,如果本地 DNS 服务器没有缓存该域名的 IP 地址,它会向更高级别的 DNS 服务器查询,直到找到对应的 IP 地址。这个过程就像是在电话簿中查找电话号码一样,通过域名找到服务器的 “电话号码”(IP 地址)。
一旦获取到 IP 地址,浏览器会与服务器建立连接。如果是http
协议,会使用TCP
协议建立连接,这包括著名的三次握手过程。首先浏览器向服务器发送一个带有SYN
标志的数据包,表示想要建立连接。服务器收到后,会返回一个带有SYN
和ACK
标志的数据包,表示收到了请求并且同意建立连接。最后,浏览器再发送一个带有ACK
标志的数据包,表示确认收到服务器的同意连接信息,这样连接就建立起来了。如果是https
协议,还会在TCP
连接的基础上建立SSL/TLS
加密连接,确保数据传输的安全性。
连接建立后,浏览器会发送 HTTP 请求到服务器。请求中包含请求方法(如GET
、POST
等)、请求头(包含用户代理、接受的内容类型等信息)和请求体(如果是POST
等有请求体的请求方法)。例如,对于一个网页请求,通常是GET
方法,请求头可能包含浏览器的类型和版本,请求体可能为空。
服务器收到请求后,会根据请求的内容进行处理。如果请求的是一个静态文件(如 HTML 文件、图片文件等),服务器会直接从文件系统中找到对应的文件,读取内容后返回给浏览器。如果请求的是一个动态资源(如通过服务器端脚本生成的网页),服务器会执行相应的脚本(如PHP
、Python
等脚本),生成最终的内容后返回给浏览器。
浏览器收到服务器返回的内容后,首先会根据内容的类型进行处理。如果是 HTML 文件,浏览器会开始解析 HTML。它会构建 DOM(文档对象模型)树,将 HTML 标签转换为 DOM 节点。在解析 HTML 的过程中,如果遇到外部资源的引用(如 CSS 文件、JavaScript 文件),浏览器会再次发送请求获取这些资源。对于 CSS 文件,浏览器会构建 CSSOM(CSS 对象模型),并将 CSS 规则应用到 DOM 树上,进行样式计算和布局。对于 JavaScript 文件,浏览器会执行脚本,可能会对 DOM 树进行修改,添加事件处理等。
最后,经过渲染引擎的处理,将构建好的带有样式的 DOM 树转换为屏幕上的像素,从而输出页面,让用户可以看到网页的内容。
说一说浏览器地址栏输入 url 到显示页面的步骤,越详细越好。在此期间打断问能说出的所有 http 状态码及其含义?js 的解析过程,遇见 async 和 defer 的时候会怎么样,没有遇见的时候会怎么样?构建 dom 树的步骤?
当在浏览器地址栏输入 URL 后,首先是 URL 解析。浏览器会把 URL 拆分成协议、主机名、路径、查询参数等部分。比如对于 “https://www.example.com/page?param=value”,会识别出协议是 “https”,主机名是 “www.example.com”,路径是 “/page”,还有查询参数 “param=value”。
接着进行 DNS 查询,通过域名系统查找主机名对应的 IP 地址。浏览器先在本地缓存中找,如果没有就向本地 DNS 服务器询问,层层查询直到找到 IP 地址。
然后是建立连接。如果是 HTTP 协议,就通过 TCP 协议进行三次握手建立连接。首先浏览器发送带有 SYN 标志的数据包,服务器收到后返回 SYN 和 ACK 标志的数据包,浏览器再发送 ACK 标志的数据包完成连接。如果是 HTTPS,还会建立 SSL/TLS 加密层。
之后浏览器发送 HTTP 请求,包括请求方法(如 GET、POST)、请求头(包含用户代理、接受内容类型等)和可能的请求体。
服务器收到请求后,根据请求内容查找资源。如果是静态资源直接读取返回,如果是动态资源(如服务器脚本生成的页面)会先执行脚本再返回。
关于 HTTP 状态码,常见的有 200,表示请求成功,服务器已成功处理请求并返回请求的数据;301,表示永久重定向,资源已被永久移动到新位置;302,表示临时重定向,资源临时移动到新位置;403,表示服务器拒绝访问,可能是权限问题;404,表示未找到资源,请求的资源在服务器上不存在;500,表示服务器内部错误,服务器在处理请求时发生错误。
对于 JavaScript 解析过程,如果没有 async 和 defer 属性,浏览器会在遇到<script>标签时,暂停 HTML 解析,下载并执行 JavaScript 代码,执行完后再继续解析 HTML。如果有 async 属性,脚本会异步下载,下载完成后立即执行,可能会在 HTML 解析过程中执行,执行顺序不确定。如果有 defer 属性,脚本会异步下载,在 HTML 解析完成后,DOMContentLoaded 事件触发之前执行,按照在文档中的顺序执行。
构建 DOM 树时,浏览器从接收到的 HTML 文档开始,以字节流的形式读取 HTML 标签。当遇到一个开始标签,就创建一个对应的 DOM 节点,遇到文本内容就把文本添加到当前节点或其父节点。遇到结束标签就完成当前节点的构建,并将其添加到父节点。这样层层构建,最终形成完整的 DOM 树。
知道浏览器缓存机制么?
浏览器缓存机制是为了减少重复请求,提高网页加载速度。
浏览器缓存主要有两种类型,强缓存和协商缓存。
强缓存是浏览器直接从本地缓存中读取资源,不向服务器发送请求。它是通过设置 Cache - Control 和 Expires 头信息来实现的。Cache - Control 是 HTTP1.1 中的属性,它有多个指令。例如,“max - age” 指令表示资源在缓存中的最大有效期,以秒为单位。如果在这个时间内再次请求相同资源,浏览器会直接使用缓存。Expires 是 HTTP1.0 中的属性,它指定一个日期时间,在这个时间之前浏览器认为资源是有效的,可以直接从缓存中获取。不过 Expires 是基于服务器时间的,如果客户端和服务器时间不一致可能会出现问题,而 Cache - Control 相对更灵活准确。
协商缓存是浏览器先向服务器发送请求,询问资源是否有更新。它是通过 Last - Modified 和 ETag 头信息来实现的。Last - Modified 表示资源最后修改的时间。浏览器第一次请求资源时,服务器会在响应头中返回 Last - Modified 的值。下次浏览器请求时,会在请求头中带上 If - Modified - Since 字段,其值为上次服务器返回的 Last - Modified 的值。服务器收到请求后,会比较资源的实际最后修改时间和 If - Modified - Since 的值,如果没有变化,就返回 304 状态码,告诉浏览器可以使用缓存,否则返回 200 状态码和新的资源。ETag 是资源的唯一标识符,是一个字符串。服务器第一次返回资源时,会在响应头中包含 ETag 的值。下次浏览器请求时,会在请求头中带上 If - ETag 的值,服务器比较资源的实际 ETag 和 If - ETag 的值来判断是否返回新资源,原理和 Last - Modified 类似。
不同类型的资源缓存策略也不同。对于 HTML 文件,通常不会长时间缓存,因为内容可能经常更新。CSS 和 JavaScript 文件可以根据版本号等适当缓存,当文件更新时可以改变文件名或版本号来让浏览器重新获取。图片等静态资源可以缓存较长时间,以提高加载速度。
Cookie 存登录信息有啥问题?
用 Cookie 存储登录信息存在一些问题。
首先是安全性问题。Cookie 中的数据是以明文形式存储在客户端的,虽然可以设置一些属性来增强安全性,但如果攻击者能够获取到用户的 Cookie,就可以获取登录信息。例如,通过跨站脚本攻击(XSS),攻击者可以在用户浏览器中注入恶意脚本,获取 Cookie 中的登录凭证,然后利用这些凭证冒充用户登录系统。
其次是大小和数量的限制。Cookie 的大小一般有限制,不同浏览器的限制不同,但通常不能存储大量的数据。而且浏览器对每个域名下的 Cookie 数量也有限制,这就限制了可以存储在 Cookie 中的登录相关信息的数量。例如,如果需要存储多个用户角色相关的信息或者多组登录相关的令牌,可能会受到 Cookie 数量限制的影响。
另外,Cookie 会随着每次 HTTP 请求发送到服务器。当一个网页包含多个资源(如多个图片、CSS 文件、JavaScript 文件),并且每个资源都来自于存储登录信息的域名时,Cookie 会在每次请求这些资源时被发送,这会增加网络开销,降低性能。而且,在跨域场景下,Cookie 的传递可能会受到限制,需要进行特殊的配置才能正确传递,否则可能会导致登录状态无法正确识别。
什么是 CSRF?说说怎么防御?
CSRF(跨站请求伪造)是一种网络安全攻击。攻击者利用用户在目标网站已登录的状态,诱导用户访问恶意网站。在用户不知情的情况下,恶意网站向目标网站发送请求,这个请求会利用用户在目标网站的登录凭证,让目标网站执行用户本不想执行的操作。
例如,用户登录了银行网站,在没有退出登录的情况下访问了一个恶意网站。恶意网站上有一个隐藏的表单,这个表单的目标是银行网站的转账接口,并且会自动提交。由于用户在银行网站是登录状态,浏览器会自动带上用户的登录 Cookie 等凭证,这样就可能导致用户的银行账户被转账。
防御 CSRF 有多种方法。一是使用验证码。在关键操作(如转账、修改密码)时,要求用户输入验证码。因为攻击者很难获取验证码,这样就可以有效防止 CSRF 攻击。
二是检查 Referer 头。服务器可以检查请求的 Referer 头,看请求是否来自合法的来源。不过这种方法有一定局限性,因为有些浏览器可能不会发送 Referer 头,或者攻击者可以伪造 Referer 头。
三是使用 CSRF 令牌。这是比较有效的方法。在用户登录后,服务器生成一个随机的 CSRF 令牌,将其存储在用户的会话中,同时发送给客户端(可以放在隐藏的表单字段或者请求头中)。当客户端发送请求时,必须带上这个 CSRF 令牌。服务器收到请求后,会检查请求中的 CSRF 令牌是否和用户会话中的一致,如果不一致就拒绝请求。这样即使攻击者诱导用户发送请求,由于没有正确的 CSRF 令牌,也无法成功。
说一说事件循环的机制,promise 是宏任务还是微任务?
事件循环是 JavaScript 用于处理异步操作的机制。
JavaScript 是单线程的,但是在执行过程中会遇到很多异步操作,如定时器、网络请求、事件处理等。事件循环的核心是有一个任务队列,任务分为宏任务和微任务。
宏任务包括整体的 script 代码、setTimeout、setInterval、I/O 操作、UI 渲染等。微任务包括 Promise 的回调函数(then、catch、finally)、MutationObserver 等。
当 JavaScript 代码开始执行时,先执行一个宏任务,这个宏任务可能会产生新的微任务或者宏任务。当一个宏任务执行完后,会立即执行所有产生的微任务。例如,在执行一个 script 代码(宏任务)过程中,遇到一个 Promise 的 then 方法(微任务),这个微任务会被添加到微任务队列中,当 script 代码这个宏任务执行完后,就会执行微任务队列中的所有微任务。
在微任务队列清空后,事件循环会查看宏任务队列,取出下一个宏任务执行,这个过程不断循环。比如有一个 setTimeout(宏任务)设置了延迟 1 秒后执行一个函数,当这个 1 秒时间到了,这个函数(宏任务)会被添加到宏任务队列中,等待前面的宏任务和微任务都执行完后才会执行。
Promise 属于微任务。当一个 Promise 被解决(resolved)或者被拒绝(rejected)时,它的 then、catch、finally 回调函数会被添加到微任务队列中,在当前宏任务执行完后就会执行这些微任务,从而实现异步操作的顺序控制。
缓存策略,详细讲一下。
浏览器缓存策略主要分为强缓存和协商缓存,目的是减少资源重复请求,提升页面加载速度。
强缓存是浏览器直接从本地缓存读取资源,不向服务器发送请求。它主要依靠 Cache - Control 和 Expires 这两个 HTTP 头信息。Cache - Control 是 HTTP1.1 的属性,有多种指令。例如 “public” 表示资源可以被任何缓存区缓存,包括代理服务器和客户端浏览器;“private” 则表示资源只能被客户端浏览器缓存。其中 “max - age” 指令很关键,它定义了资源在缓存中的最大有效时间,单位是秒。如 “Cache - Control: max - age = 3600”,意味着在接下来的 1 小时内,浏览器对该资源的请求直接使用缓存。Expires 是 HTTP1.0 的属性,它指定一个日期时间,在这个时间之前,浏览器认为资源是有效的,会直接从缓存中获取。不过它是基于服务器时间的,如果客户端和服务器时间不一致,可能导致缓存失效判断错误。
协商缓存是浏览器先向服务器发送请求,询问资源是否更新,再决定是否使用本地缓存。协商缓存通过 Last - Modified 和 ETag 这两个头信息实现。Last - Modified 是资源最后修改的时间。首次请求时,服务器在响应头中返回 Last - Modified 的值。下次请求时,浏览器在请求头中带上 If - Modified - Since 字段,其值为上次服务器返回的 Last - Modified 的值。服务器收到请求后,比较资源实际的最后修改时间和 If - Modified - Since 的值,若没有变化,返回 304 状态码,告诉浏览器可以使用缓存,否则返回 200 状态码和新资源。ETag 是资源的唯一标识符,是一个字符串。服务器首次返回资源时,在响应头中包含 ETag 的值。下次请求时,浏览器在请求头中带上 If - ETag 的值,服务器比较资源实际的 ETag 和 If - ETag 的值来判断是否返回新资源。
不同类型的资源有不同的缓存策略。对于 HTML 文件,因为内容可能频繁更新,所以缓存时间通常较短。CSS 和 JavaScript 文件可以根据版本号等适当缓存,更新文件时改变文件名或版本号让浏览器重新获取。图片等静态资源可缓存较长时间,以加快加载速度。另外,浏览器的缓存存储位置也有多种,包括内存缓存和磁盘缓存。内存缓存速度快,但容量有限,用于存储近期频繁访问的资源;磁盘缓存容量大,用于存储不那么频繁访问的资源。
项目中跨域问题怎么解决的,有了解其他解决方法吗?
在项目中,跨域问题是比较常见的。一种解决方法是 JSONP。JSONP 利用了<script>标签不受同源策略限制的特点。当需要从不同域获取数据时,服务器返回的数据被包裹在一个函数调用中。例如,客户端定义一个函数callbackFunction
,服务器返回的数据格式为callbackFunction(data)
,其中data
是真正要返回的数据。在客户端通过动态创建<script>标签,将其src
属性设置为跨域的 URL,这样浏览器就会加载这个脚本并执行其中的函数,从而获取数据。不过 JSONP 只能用于 GET 请求,因为它本质上是通过加载脚本实现的。
另一种方法是 CORS(跨域资源共享)。这是一种更现代、更灵活的跨域解决方案。服务器端通过设置响应头来允许跨域访问。例如,服务器可以设置Access - Control - Allow - Origin
头,其值可以是允许访问的域名,如http://example.com
,也可以是*
,表示允许任何域名访问。同时,还可以设置其他相关头信息,如Access - Control - Allow - Methods
来指定允许的请求方法(如 GET、POST 等),Access - Control - Allow - Headers
来指定允许的请求头。在客户端,浏览器会自动处理这些头信息,对于允许跨域的请求,就可以正常获取数据。
代理服务器也是解决跨域问题的一种方式。在开发环境中,可以在本地设置一个代理服务器。例如,在使用 Webpack 开发时,可以配置devServer
的proxy
选项。当客户端请求一个跨域资源时,请求先发送到本地代理服务器,代理服务器再向真正的目标服务器发送请求,然后将返回的数据传递给客户端。这样在目标服务器看来,请求是来自代理服务器,而不是跨域的客户端,从而绕过了跨域限制。
刚刚提到了浏览器的同源策略,讲一下为什么为什么需要同源策略去限制,会限制哪些行为?
浏览器的同源策略是一种安全机制。之所以需要同源策略限制,是为了防止恶意网站获取用户在其他网站的敏感信息并进行非法操作。
如果没有同源策略,当用户访问一个恶意网站时,这个恶意网站可以通过 JavaScript 等方式访问用户在其他合法网站的登录凭证、个人数据等信息,然后进行各种恶意行为。例如,用户在银行网站登录后,其登录状态信息(如 Cookie)存储在浏览器中。若没有同源策略,恶意网站可以通过脚本获取银行网站的 Cookie,然后冒充用户进行转账等操作。
同源策略主要限制以下几种行为。一是 DOM 访问限制。不同源的网页之间不能直接访问对方的 DOM 元素。例如,一个网站的页面不能通过 JavaScript 获取另一个不同源网站页面中的 HTML 元素及其内容。这可以防止恶意网站篡改其他网站的页面内容。
二是 Cookie、LocalStorage 和 SessionStorage 限制。浏览器默认情况下不会将一个网站的 Cookie 等存储信息发送给不同源的网站。这样可以保护用户的登录状态和其他存储在本地的数据,防止信息泄露。
三是 XMLHttpRequest 和 Fetch 等网络请求限制。在浏览器中,这些用于获取数据的方式会受到同源策略的限制。一般情况下,不能使用它们直接向不同源的服务器发送请求。不过可以通过一些跨域解决方案(如 CORS)来允许合法的跨域请求,以在保证安全的基础上实现必要的数据交互。
刚刚提到了安全问题,讲一讲对前端安全的了解 (XSS,CSRF)
前端安全中,XSS(跨站脚本攻击)和 CSRF(跨站请求伪造)是比较重要的两个方面。
XSS 攻击是指攻击者通过在目标网站中注入恶意脚本,来获取用户信息或者执行其他恶意操作。XSS 攻击分为三种类型:存储型 XSS、反射型 XSS 和 DOM - based XSS。
存储型 XSS 是最危险的一种。攻击者将恶意脚本注入到目标网站的服务器端,例如在一个论坛的评论区,攻击者提交包含恶意脚本的评论。当其他用户访问包含这个评论的页面时,浏览器会执行这个恶意脚本。这个脚本可能会窃取用户的登录凭证(如 Cookie),然后发送给攻击者,或者修改页面内容,引导用户进行一些非法操作。
反射型 XSS 是攻击者通过构造一个带有恶意脚本的 URL,诱使用户点击。当用户访问这个 URL 时,服务器会将恶意脚本反射回浏览器并执行。例如,攻击者构造一个包含恶意脚本的搜索链接,当用户点击这个链接并在服务器端进行搜索操作时,服务器返回的搜索结果页面会包含并执行这个恶意脚本。
DOM - based XSS 是通过修改页面的 DOM 节点来执行恶意脚本。例如,攻击者利用 JavaScript 中的document.write
或者innerHTML
等函数,在页面中插入恶意脚本。
对于 XSS 的防御,可以对用户输入进行过滤和转义。在服务器端和客户端,对用户输入的内容(如评论、表单数据等)进行检查,过滤掉可能包含恶意脚本的字符,如<script>
标签等。同时,设置合适的 CSP(内容安全策略),CSP 可以限制页面加载的资源来源,防止恶意脚本的加载。
CSRF 攻击是攻击者利用用户在目标网站已登录的状态,诱导用户访问恶意网站,然后通过用户在目标网站的登录凭证,让目标网站执行用户本不想执行的操作。例如,用户登录银行网站后,访问恶意网站,恶意网站上有一个隐藏的表单,其目标是银行网站的转账接口并且会自动提交。由于用户在银行网站是登录状态,浏览器会自动带上用户的登录 Cookie 等凭证,导致转账操作可能被执行。
防御 CSRF 可以采用多种方法。一是使用验证码,在关键操作(如转账、修改密码)时,要求用户输入验证码,这样攻击者很难获取验证码,从而有效防止 CSRF 攻击。二是检查 Referer 头,服务器可以检查请求的 Referer 头,看请求是否来自合法的来源,不过这种方法有一定局限性。三是使用 CSRF 令牌,在用户登录后,服务器生成一个随机的 CSRF 令牌,将其存储在用户的会话中,同时发送给客户端(可以放在隐藏的表单字段或者请求头中)。当客户端发送请求时,必须带上这个 CSRF 令牌,服务器收到请求后,会检查请求中的 CSRF 令牌是否和用户会话中的一致,不一致则拒绝请求。
了解 https 吗,讲一下原理。
HTTPS(超文本传输安全协议)是一种在 HTTP 基础上通过 SSL/TLS 协议进行加密传输的网络协议。
SSL/TLS 协议主要用于在客户端和服务器之间建立一个安全的加密通道。它的核心是加密和身份验证。
在加密方面,SSL/TLS 使用了非对称加密和对称加密相结合的方式。首先是非对称加密。服务器会生成一对密钥,包括公钥和私钥。公钥可以公开给客户端,私钥则由服务器自己保存。当客户端和服务器建立连接时,客户端会向服务器请求公钥。然后客户端使用这个公钥对一个随机生成的对称密钥进行加密,并发送给服务器。服务器使用自己的私钥解密这个消息,得到对称密钥。之后,客户端和服务器之间的数据传输就使用这个对称密钥进行加密。对称加密的效率比非对称加密高,所以在大量数据传输时使用对称加密可以提高传输效率。
在身份验证方面,服务器会向客户端提供一个数字证书。这个数字证书是由受信任的证书颁发机构(CA)颁发的。证书中包含了服务器的公钥和一些其他信息,如服务器的域名等。客户端收到证书后,会验证证书的有效性。客户端会检查证书是否是由信任的 CA 颁发的,证书中的域名是否和实际访问的域名一致,以及证书是否在有效期内等。如果证书验证通过,客户端就可以信任服务器,并继续进行数据传输。
通过这种加密和身份验证机制,HTTPS 可以有效防止数据在传输过程中被窃取、篡改,同时确保客户端访问的是真实的服务器,而不是被伪装的服务器,大大提高了网络通信的安全性。
说说 p 元素嵌套 div 标签是否符合规范?
在 HTML 的规范中,p 元素嵌套 div 标签是不符合规范的。
p 元素是段落元素,它主要用于定义文本段落,其语义是明确表示一段连续的文本内容。而 div 元素是一个通用的容器元素,用于对页面进行布局划分、组合相关的内容等,它并没有特定的文本语义。
按照标准的 HTML 语法,p 元素内部应该只包含文本内容、内联元素(如 a、em、strong 等)以及其他符合在段落内出现的元素,但不应该直接嵌套块级元素,div 就是典型的块级元素。如果将 div 嵌套在 p 元素内部,这会破坏 HTML 文档的结构语义,并且在浏览器渲染时可能会出现一些意想不到的结果。
例如,当浏览器解析到这种不符合规范的嵌套时,它可能会尝试自动修正这种错误结构,通常会将 p 元素在 div 开始处截断,使得原本在一个段落里的文本被分割成不同部分,分别属于不同的 p 元素,这就改变了原本想要表达的文本段落的连贯性和整体性,进而影响页面内容的正确呈现和用户对文本内容的理解。所以在编写 HTML 代码时,应遵循正确的元素嵌套规则,避免出现 p 元素嵌套 div 标签这种不符合规范的情况。
块级元素,行内元素区别是什么?
块级元素和行内元素在 HTML 中有明显的区别,主要体现在以下几个方面:
布局表现
- 块级元素:块级元素在页面布局中会独占一行,从上到下依次排列。比如常见的 div、p、h1 - h6 等元素都是块级元素。无论其内容多少,它都会占据整行的宽度,并且在其前后会产生换行效果。即使设置了宽度小于父容器的宽度,它仍然会独占一行,剩余的空间为空。例如,一个 div 元素即使设置了宽度为 50%,它还是会单独占据一行,旁边的空间不会被其他块级元素自动填充。
- 行内元素:行内元素则不会独占一行,它们会在一行内按照从左到右的顺序依次排列,直到一行排不下才会换行。像 a、em、strong、span 等都是行内元素。例如,在一行内可以连续放置多个 a 元素、em 元素等,它们会紧挨着排列,只有当这一行的宽度无法容纳更多的行内元素时,才会换行继续排列。
宽度和高度设置
- 块级元素:块级元素可以方便地设置宽度和高度属性。可以通过 CSS 明确指定其宽度为具体的数值(如 width: 200px)、百分比(如 width: 50%)等,同样也能设置高度属性(如 height: 100px)。并且其默认宽度通常是父容器的宽度(除非有特殊设置使其宽度变小),默认高度是由其内容撑开的,如果内容为空,高度可能为 0(具体也看浏览器的默认处理)。
- 行内元素:行内元素在默认情况下不能直接设置宽度和高度属性。如果强行设置宽度和高度,在大多数浏览器中并不会按照设置的值来呈现效果。行内元素的宽度和高度是由其内容自动决定的,即内容有多少,它的宽度和高度大致就是多少。例如,一个 span 元素里面只有一个单词,那么它的宽度就是这个单词的宽度,高度也是由这个单词的字体大小等因素决定的。
内外边距设置
- 块级元素:块级元素的外边距(margin)和内边距(padding)设置会按照预期的方式影响元素的布局。外边距可以用来控制元素与周围元素的间隔,内边距则可以用来调整元素内部内容与边框之间的距离。例如,设置一个 div 元素的外边距为 10px,会使它与相邻元素之间产生 10px 的间隔;设置内边距为 5px,会使内部内容与边框之间有 5px 的距离。
- 行内元素:行内元素的外边距和内边距设置在垂直方向上的效果与块级元素不同。在水平方向上,外边距和内边距设置基本按照预期起作用,比如设置一个 a 元素的水平外边距为 5px,会使它与相邻的行内元素在水平方向上产生 5px 的间隔。但在垂直方向上,外边距设置虽然有值,但通常不会产生明显的垂直间隔效果(不同浏览器可能有细微差异),内边距设置在垂直方向上主要是影响元素内部内容的垂直位置,但不会使元素在垂直方向上产生明显的间隔变化。
包含子元素情况
- 块级元素:块级元素可以包含其他块级元素、行内元素以及文本内容等各种类型的元素。例如,一个 div 元素可以里面嵌套多个 p 元素、span 元素、甚至其他 div 元素等,它就像一个大的容器,可以容纳多种不同类型的内容和子元素。
- 行内元素:行内元素一般只能包含其他行内元素和文本内容,通常不建议直接包含块级元素(虽然有些浏览器可能会尝试处理这种情况,但不符合规范且可能导致布局异常)。比如,一个 span 元素里面可以有 em 元素、a 元素以及一些文本,但如果放入一个 div 元素,就可能会引起布局问题,因为这不符合行内元素的正常使用规则。
JavaScript 基础相关:如何实现一个 const?
在 JavaScript 中,const 是用于声明常量的关键字。要实现一个 const 声明,语法非常简单,格式如下:
const variableName = value;
其中,variableName
是要声明的常量的名称,它需要遵循 JavaScript 中变量命名的规则,比如不能以数字开头、不能包含特殊字符(除了下划线和美元符号)等。value
是赋予这个常量的初始值,而且这个初始值在声明之后是不可以被修改的。
例如:
const PI = 3.14159;
const greeting = "Hello World";
在上述例子中,PI
被声明为一个常量,其值为圆周率的近似值 3.14159,greeting
被声明为一个常量,其值为 "Hello World"。
一旦使用 const 声明了一个常量,就不能再对其进行重新赋值操作。如果尝试这样做,在严格模式下,JavaScript 会抛出一个类型错误(TypeError)。例如:
const myNumber = 5;
myNumber = 10; // 这行代码会导致错误,因为不能重新赋值给const声明的常量
需要注意的是,虽然 const 声明的常量其值本身不能被修改,但如果这个常量是一个对象或者数组,那么其内部的属性或元素是可以被修改的。例如:
const myObject = {name: "John", age: 30};
myObject.age = 35; // 这是可以的,因为只是修改了对象内部的属性值,而不是重新赋值整个对象
同样对于数组:
const myArray = [1, 2, 3];
myArray.push(4); // 这也是可以的,因为只是在数组内部添加元素,而不是重新赋值整个数组
说说作用域链,作用域链具体怎么执行的,用什么变量查的?
在 JavaScript 中,作用域链是一个用于确定变量访问权限的机制。
作用域链的形成
当代码在 JavaScript 中执行时,每个函数都会创建一个自己的作用域。函数内部定义的变量只能在该函数内部访问,这就是函数作用域。而全局作用域是在代码最外层定义的变量可以被整个代码中的任何地方访问(除非在函数内部有同名的局部变量覆盖)。
当一个函数嵌套在另一个函数内部时,就会形成作用域链。例如,有函数 A 和函数 B,函数 B 嵌套在函数 A 内部。那么在函数 B 内部访问变量时,首先会在函数 B 自身的作用域内查找,如果没有找到,就会沿着作用域链向上,到函数 A 的作用域中查找,如果还没有找到,就会继续向上到全局作用域中查找。
作用域链的执行
作用域链具体执行的过程是当代码在执行到需要访问一个变量时,会从当前函数的作用域开始查找。比如在函数 B 中需要访问一个变量 x,如果函数 B 自身作用域内有定义变量 x,那么就直接使用这个变量。
如果在函数 B 自身作用域内没有找到变量 x,那么就会按照作用域链向上一级的作用域(也就是函数 A 的作用域)去查找。在函数 A 的作用域中,如果找到了变量 x,那么就使用这个变量的值进行后续的操作。
如果在函数 A 的作用域中也没有找到变量 x,那么就会继续向上到全局作用域中查找。如果在全局作用域中找到了变量 x,那么就使用这个变量的值;如果在全局作用域中也没有找到,那么就会抛出一个引用错误(ReferenceError),表示未找到该变量。
用什么变量查的
在整个作用域链的查找过程中,就是用要访问的那个具体的变量名来进行查找的。比如前面说的要找变量 x,那么就始终是围绕着变量 x 这个名称在各个作用域中去查看是否有定义。而且在查找过程中,是按照从内到外的顺序,先从当前函数的作用域开始,逐步向上一级的作用域推进,直到找到该变量或者到达全局作用域且未找到为止。
例如,假设有如下代码:
let globalVar = "I'm global";
function outerFunction() {
let outerVar = "I'm outer";
function innerFunction() {
let innerVar = "I'm inner";
console.log(outerVar); // 这里会在作用域链上查找outerVar,先在innerFunction的作用域内找,没找到就向上到outerFunction的作用域找到并使用
console.log(globalVar); // 这里会在作用域链上查找globalVar,先在innerFunction的作用域内找,没找到就向上到outerFunction的作用域,再没找到就向上到全局作用域找到并使用
}
innerFunction();
}
outerFunction();
在这个例子中,当在innerFunction
中访问outerVar
和globalVar
时,就是通过作用域链按照上述的查找方式来确定是否能找到以及使用这些变量的值。
怎么改变函数的作用域?
在 JavaScript 中,有几种方法可以改变函数的作用域。
一种常见的方法是使用call
或apply
方法。这两个方法都是函数对象的方法,它们允许在特定的对象上下文中调用函数,从而改变函数内部this
的指向,也就改变了函数的作用域。
call
方法的语法是function.call(thisArg, arg1, arg2,...)
。其中thisArg
是要绑定给函数内部this
的值,也就是改变后的作用域对象,后面的arg1, arg2,...
是函数的参数。例如,有一个函数showName
定义为function showName() { console.log(this.name); }
,假设有一个对象obj1 = { name: 'John' }
和obj2 = { name: 'Alice' }
,可以通过showName.call(obj1)
将函数showName
的作用域改变到obj1
,此时函数内部的this
指向obj1
,就会输出John
。
apply
方法的语法是function.apply(thisArg, [arg1, arg2,...])
。它和call
方法类似,不同的是参数传递方式。apply
接收的第二个参数是一个数组,数组中的元素作为函数的参数。比如对于上述的showName
函数,若使用showName.apply(obj2, [])
,也能将函数的作用域改变到obj2
,输出Alice
。
另一种方法是使用bind
方法。bind
方法会创建一个新的函数,新函数的this
值被绑定到指定的对象。语法是function.bind(thisArg, arg1, arg2,...)
。和call
、apply
不同的是,bind
不会立即执行函数,而是返回一个绑定了this
值的新函数。例如,let newShowName = showName.bind(obj1);
,此时newShowName
函数的this
已经绑定到obj1
,当调用newShowName()
时,就会输出John
。这种绑定后的函数可以在需要的时候调用,并且始终保持绑定的作用域。
此外,在函数内部使用that = this
这种方式也可以在一定程度上改变作用域的引用。例如,在一个事件处理函数中,this
通常指向触发事件的元素,但是如果在函数内部先将this
赋值给另一个变量(如that
),在后续的异步操作或者嵌套函数中就可以通过that
来访问原本的this
所指向的对象,避免this
指向改变带来的问题。
连续 bind(比如 xx.bind (this01).bind (this02))执行的是哪个?为什么?
在 JavaScript 中,当连续使用bind
方法,如xx.bind(this01).bind(this02)
,最终执行的是绑定到this01
的函数。
bind
方法会创建一个新的函数,并且这个新函数的this
指向被绑定的对象。当第一次使用bind(this01)
时,它返回一个新的函数,这个新函数内部的this
已经被永久地绑定到了this01
。
然后对这个新函数再使用bind(this02)
,实际上是对已经绑定了this01
的新函数再次进行绑定操作。但bind
方法本身的特性是,它不会修改已经绑定好的this
指向。第二次的bind
操作只是返回一个新的函数,这个新函数内部的this
仍然是指向第一次绑定的this01
。
可以通过以下方式来理解。假设xx
是一个函数,bind
方法的实现类似于下面的代码(简化版):
Function.prototype.myBind = function (context) {
let self = this;
return function () {
return self.apply(context, arguments);
};
};
当执行xx.bind(this01)
时,相当于返回一个新函数,这个新函数内部的this
会被设置为this01
。当对这个新函数再执行bind(this02)
时,它又会返回一个新函数,但是这个新函数内部调用原函数(xx
)时,this
还是按照第一次绑定的this01
来执行。
从函数的本质来看,bind
是对函数的一种包装,每次bind
操作都是基于上一次包装后的函数进行新的包装,而内部原函数的this
指向是在第一次bind
操作时就确定好了,后续的bind
操作不会改变这个最初的绑定。
说说冒泡排序和选择排序。
冒泡排序和选择排序是两种常见的基本排序算法。
冒泡排序
冒泡排序的基本思想是比较相邻的元素,如果顺序不对就进行交换,就像气泡一样,较轻的(较小的)元素会逐渐 “浮” 到上面(前面)。
例如,对于一个数组[5, 4, 3, 2, 1]
进行冒泡排序。它会从数组的第一个元素开始,依次比较相邻的两个元素。第一轮比较,会比较5
和4
,因为5 > 4
,所以交换它们的位置,数组变为[4, 5, 3, 2, 1]
。接着比较5
和3
,交换后变为[4, 3, 5, 2, 1]
,以此类推。第一轮结束后,最大的元素5
就会 “浮” 到数组的最后面。
然后进行第二轮比较,这一轮比较就不需要考虑最后一个元素(已经是最大的),所以只比较前面的元素,经过类似的操作,第二大的元素会被交换到倒数第二个位置。这样依次进行,总共需要进行n - 1
轮比较(n
是数组的长度),每一轮比较的次数会逐渐减少。
冒泡排序的代码实现如下:
function bubbleSort(arr) {
let n = arr.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
let temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
return arr;
}
选择排序
选择排序的基本思想是在未排序的序列中找到最小(或最大)的元素,将其放在序列的起始位置,然后在剩余的未排序元素中继续寻找最小(或最大)元素,放在已排序序列的末尾,以此类推。
对于同样的数组[5, 4, 3, 2, 1]
进行选择排序。第一轮,它会遍历整个数组,找到最小的元素1
,然后将1
和数组的第一个元素5
交换位置,数组变为[1, 4, 3, 2, 5]
。接着,在剩下的[4, 3, 2, 5]
中找到最小的元素2
,将其与第二个元素4
交换位置,变为[1, 2, 3, 4, 5]
。
选择排序的代码实现如下:
function selectionSort(arr) {
let n = arr.length;
for (let i = 0; i < n - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
if (minIndex!== i) {
let temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
return arr;
}
两种排序算法的时间复杂度都是,在数据量较小的情况下,它们的性能差异不大。但是在数据量较大时,它们的效率都比较低,通常会使用更高效的排序算法,如快速排序、归并排序等。
字符串与对象 new String 区别是什么?
在 JavaScript 中,字符串字面量和通过new String()
创建的对象有一些区别。
类型判断
- 字符串字面量:当使用字符串字面量(如
let str1 = "Hello"
)时,typeof str1
的结果是string
,这是 JavaScript 的基本数据类型之一。它在内存中的存储相对简单,直接存储字符序列。 - new String 对象:当使用
new String("Hello")
创建对象时,typeof
操作的结果是object
。这是因为通过new
关键字创建的是一个对象,它是String
的一个实例,这个对象有一些额外的属性和方法。
比较操作
- 字符串字面量:对于字符串字面量,比较操作是基于字符序列的值进行的。例如,
let str2 = "Hello"
,str1 === str2
会返回true
,因为它们的字符序列完全相同。这种比较是按照值来判断的。 - new String 对象:对于
new String
创建的对象,比较操作比较复杂。如果使用===
进行比较,比较的是对象的引用,而不是对象内部的字符串值。例如,let str3 = new String("Hello")
,let str4 = new String("Hello")
,str3 === str4
会返回false
,因为它们是两个不同的对象引用,即使它们内部的字符串值相同。如果想要比较new String
对象内部的字符串值,可以使用str3.valueOf() === str4.valueOf()
或者str3.toString() === str4.toString()
,这样会返回true
。
性能和内存占用
- 字符串字面量:由于字符串字面量是基本数据类型,在性能和内存占用方面通常更有优势。它们在代码执行过程中处理起来相对简单,不需要额外的对象开销。
- new String 对象:
new String
对象由于是对象,会有额外的内存开销,包括对象的属性和方法等占用的空间。在性能方面,由于对象的操作相对复杂,如访问属性和方法等,可能会比直接处理字符串字面量稍微慢一些。
可变性
- 字符串字面量:字符串字面量在 JavaScript 中是不可变的。这意味着一旦创建了一个字符串,就不能修改它的内容。例如,
let str5 = "Hello"
,如果想把str5
中的H
换成h
,不能直接修改str5
,而是需要创建一个新的字符串,如let newStr = "h" + str5.slice(1)
。 - new String 对象:
new String
对象本身是一个对象,虽然其内部的字符串值在本质上也是不可变的(因为 JavaScript 的字符串不可变特性),但是可以通过对象的属性和方法来模拟一些可变的操作。例如,可以在对象上添加新的属性来记录相关信息,不过这并没有改变内部字符串本身的不可变性质。
js 中 array 怎么判断是 array?
在 JavaScript 中有几种方法可以判断一个变量是否是数组。
一种常用的方法是使用Array.isArray()
方法。这个方法是 JavaScript 原生提供的,专门用于判断一个对象是否是数组。例如,let arr = [1, 2, 3];
,Array.isArray(arr)
会返回true
。而如果是一个非数组对象,如let obj = {name: "John"};
,Array.isArray(obj)
会返回false
。
另一种方法是使用instanceof
运算符。instanceof
用于检查一个对象是否是某个构造函数的实例。对于数组,因为数组是通过Array
构造函数创建的,所以可以使用arr instanceof Array
来判断。如果arr
是一个数组,那么这个表达式会返回true
。不过需要注意的是,instanceof
的判断是基于原型链的。如果在不同的执行环境或者有复杂的继承关系时,可能会出现不准确的情况。例如,在一个 iframe 中,其内部的数组使用instanceof
判断可能会与主页面的Array
构造函数不匹配。
还可以通过对象的toString()
方法来判断。每个对象都有toString()
方法,数组对象的toString()
方法返回的是一个由数组元素组成的字符串,格式为以逗号分隔的元素列表,并且其toString()
方法是继承自Array.prototype.toString()
。可以通过Object.prototype.toString.call(obj)
来获取对象的准确类型字符串,对于数组,这个方法会返回[object Array]
。所以可以这样判断:Object.prototype.toString.call(arr) === "[object Array]"
来确定arr
是否是数组。这种方法比较准确,不受执行环境和继承关系的影响。
浏览器前端存储有哪些方式?
浏览器前端存储主要有以下几种常见方式:
Cookie
- 特点:Cookie 是一种较为古老的存储方式。它由服务器通过响应头设置发送给浏览器,然后浏览器会在后续请求中将 Cookie 信息自动携带发送回服务器。Cookie 可以设置过期时间,过期后会被自动删除。它的大小通常有限制,一般每个 Cookie 不能超过 4KB 左右,且每个域名下能存储的 Cookie 数量也有限制。
- 用途:常用于存储用户的登录状态、用户偏好设置等信息。例如,当用户登录一个网站后,服务器会通过 Cookie 设置一个标识用户登录状态的令牌,下次用户访问该网站时,浏览器携带此 Cookie,服务器就能识别出用户已登录。
LocalStorage
- 特点:LocalStorage 提供了一种在浏览器本地持久化存储数据的方式。它的数据存储是没有过期时间的,除非用户手动清除浏览器缓存或者通过代码进行删除操作。它的存储容量相对较大,一般在 5MB 左右,不同浏览器可能会有差异。数据是以键值对的形式存储的,并且只能存储字符串类型的数据。如果要存储其他类型的数据(如对象、数组),需要先将其转换为字符串(如使用 JSON.stringify),读取时再进行相应的转换(如使用 JSON.parse)。
- 用途:适合存储一些不需要频繁与服务器交互的数据,且需要长期保存的信息。比如应用程序的配置信息、用户的本地设置(如主题颜色选择、字体大小设置等),即使浏览器关闭后再次打开,这些数据依然可以被获取使用。
SessionStorage
- 特点:SessionStorage 与 LocalStorage 类似,也是以键值对形式存储数据。但它的存储是基于会话的,当浏览器窗口或标签页关闭时,存储在 SessionStorage 中的数据就会被自动清除。它同样只能存储字符串类型的数据,存储容量也和 LocalStorage 相近。
- 用途:常用于在一个会话期间临时存储一些数据。比如在一个多页面的 Web 应用中,用户在一个页面进行了某些操作(如填写了部分表单信息),在跳转到下一个页面时,可以通过 SessionStorage 将这些信息暂时存储起来,以便在后续页面中继续使用,当整个会话结束(关闭窗口或标签页),这些数据就不再需要保留。
IndexedDB
- 特点:IndexedDB 是一种更强大的浏览器端数据库存储方式。它支持存储大量结构化数据,提供了异步的 API 进行数据操作,包括创建数据库、创建对象仓库(类似表)、添加、删除、更新和查询数据等操作。它不像前面几种存储方式那样简单地以键值对形式存储,而是可以处理更复杂的数据结构和关系。它的存储容量理论上比 LocalStorage 和 SessionStorage 要大得多,通常只受到浏览器磁盘空间的限制。
- 用途:适合用于存储复杂的应用数据,如大型的离线应用程序,需要存储大量的文档、图片信息(以二进制数据等形式)、用户的历史记录等复杂且需要进行查询和管理的数据。
Web Storage API(包括 LocalStorage 和 SessionStorage)
- 特点:提供了统一的 API 来操作 LocalStorage 和 SessionStorage。可以通过
localStorage.setItem('key', 'value')
和sessionStorage.setItem('key', 'value')
来设置数据,通过localStorage.getItem('key')
和sessionStorage.getItem('key')
来获取数据,以及通过localStorage.removeItem('key')
和sessionStorage.removeItem('key')
来删除数据等操作。 - 用途:方便开发者在需要进行本地存储操作时,快速且统一地使用这些存储方式,根据具体需求选择是要长期保存(LocalStorage)还是在会话期间保存(SessionStorage)数据。
== 与 === 的区别,在此期间 js 做了什么事情?
在 JavaScript 中,==
和===
都是用于比较操作的,但它们之间存在明显区别。
比较规则区别
- ==(宽松相等):
==
在进行比较时,会先尝试进行类型转换,然后再比较值是否相等。例如,当比较5
和"5"
时,==
会将字符串"5"
转换为数字类型,然后再与数字5
进行比较,此时比较结果为true
。它会根据一定的规则进行类型转换,比如数字和字符串之间可以相互转换,布尔值true
会转换为数字1
,false
会转换为数字0
等。 - ===(严格相等):
===
则是严格的相等比较,它不仅要求比较的值相等,还要求比较的两个对象或数据类型完全相同。继续以5
和"5"
为例,使用===
进行比较时,因为数字5
和字符串"5"
的类型不同,所以比较结果为false
。只有当比较的两个对象类型相同且值也相同,比如5
和5
(都是数字类型且值相等),或者"hello"
和"hello"
(都是字符串类型且值相等)时,===
的比较结果才为true
。
JavaScript 在比较时所做的事情
- ==:当使用
==
进行比较时,JavaScript 会按照以下大致步骤进行操作:- 首先判断比较的两个操作数是否为同一类型。如果是同一类型,则直接比较值是否相等。例如,比较两个数字或者两个字符串等。
- 如果不是同一类型,就会根据类型转换规则进行转换。比如,如果一个是数字,一个是字符串,就会将字符串转换为数字类型后再进行比较。如果一个是布尔值,一个是数字,就会将布尔值转换为数字后再进行比较。具体的转换规则是比较复杂的,涉及到不同类型之间的相互转换,但总体原则是尽可能使两者类型一致后再比较值。
- 在完成类型转换后,如果能得到明确的比较结果(要么相等要么不相等),就返回相应的结果。如果在转换过程中出现无法确定或不合理的情况(比如将一个对象尝试转换为数字类型时遇到困难),可能会返回
false
或者抛出异常(这取决于具体的情况和代码环境)。
- ===:当使用
===
进行比较时,JavaScript 的操作相对简单:- 直接检查比较的两个操作数的类型是否相同。如果类型不同,立即返回
false
。 - 如果类型相同,再比较值是否相等。如果值相等,返回
true
;如果值不相等,返回false
。
- 直接检查比较的两个操作数的类型是否相同。如果类型不同,立即返回
所以,总体来说,===
的比较更加严格和准确,能避免一些因类型转换带来的潜在问题,而==
在某些情况下可能会因为类型转换而得到一些意想不到的结果,在编写代码时需要根据具体情况谨慎选择使用哪种比较方式。
对 JavaScript 的了解 (很多追问),包括讲一下 Eventloop。
JavaScript 是一种广泛应用于前端开发的编程语言,具有以下诸多特点和功能:
变量声明与类型
- 变量声明方式:JavaScript 有多种变量声明方式,如
var
、let
、const
。var
是比较传统的声明方式,它存在变量提升的问题,即变量可以在声明之前被使用,其作用域通常是函数级别的。let
用于声明块级变量,它解决了var
的变量提升问题,并且其作用域限定在块级范围内,如if
语句、for
循环等内部的变量可以使用let
进行声明,使其作用域更加清晰。const
主要用于声明常量,一旦声明,其值不能被修改,不过如果声明的常量是一个对象或者数组,其内部的属性或元素可以被修改。 - 数据类型:JavaScript 有基本数据类型和引用数据类型。基本数据类型包括数字(如整数、小数)、字符串、布尔值、
null
、empty
等。引用数据类型主要有对象(包括普通对象、数组、函数等)。不同类型的数据在内存中的存储方式和操作特性有所不同。例如,基本数据类型的值是直接存储的,而引用数据类型是存储对象的引用地址,通过引用地址来访问和操作对象。
函数与作用域
- 函数定义与调用:函数是 JavaScript 中的重要组成部分。可以通过多种方式定义函数,如函数声明式(
function name() {}
)和函数表达式(let name = function() {}
)。函数可以接受参数并返回值,在调用时将实际参数传递给函数,函数内部根据参数进行相应的操作并返回结果。 - 作用域:JavaScript 有函数作用域和全局作用域。函数内部定义的变量只能在该函数内部访问,这就是函数作用域。全局作用域是指在代码最外层定义的变量可以被整个代码中的任何地方访问(除非在函数内部有同名的局部变量覆盖)。此外,当函数嵌套时,还会形成作用域链,即在内部函数中访问变量时,会先在自身作用域内查找,如果没找到,就会沿着作用域链向上一级作用域查找,直到找到或到达全局作用域。
异步编程与 Eventloop
- 异步编程的必要性:JavaScript 是单线程的语言,这意味着在同一时刻只能执行一个任务。但在实际应用中,我们经常需要处理一些异步操作,如网络请求、定时器操作、事件处理等。如果没有异步编程机制,当遇到这些需要等待的操作时,整个程序就会停滞不前,等待操作完成后才能继续执行下一个任务,这显然不符合实际需求。
- Eventloop 机制:Eventloop 是 JavaScript 处理异步操作的核心机制。它主要由以下几个部分组成:
- Call Stack(调用栈):这是 JavaScript 执行代码的地方,当执行一个函数时,它会被压入调用栈,函数执行完后会从调用栈中弹出。例如,当我们执行一个简单的脚本代码,里面有多个函数的调用,这些函数会依次进入调用栈进行执行。
- Heap(堆):堆是用于存储对象和数组等引用数据类型的地方。当我们创建一个对象或者数组时,它会被存储在堆中,通过引用地址与调用栈中的变量进行联系。
- Task Queue(任务队列):任务队列又分为宏任务队列和微任务队列。宏任务包括整体的 script 代码、
setTimeout
、setInterval
、I/O操作
、UI渲染
等。微任务包括Promise
的回调函数(then
、catch
、finally
)、MutationObserver
等。当一个宏任务执行完后,会立即执行所有产生的微任务。 - Eventloop 循环过程:当 JavaScript 代码开始执行时,首先执行一个宏任务,这个宏任务可能会产生新的微任务或者宏任务。当一个宏任务执行完后,会立即执行所有产生的微任务。在微任务队列清空后,事件循环会查看宏任务队列,取出下一个宏任务执行,这个过程不断循环。例如,当执行一个
script
代码(宏任务)过程中,遇到一个Promise
的then
方法(微任务),这个微任务会被添加到微任务队列中,当script
代码这个宏任务执行完后,就会执行微任务队列中的所有微任务。然后再从宏任务队列中选取下一个宏任务进行执行,如此循环往复,从而实现了对异步操作的处理。
面向对象编程特性
- 原型与继承:JavaScript 虽然没有像传统面向对象语言那样的类定义方式,但它通过原型来实现继承。每个对象都有一个原型对象,通过
__proto__
(在一些浏览器环境下)或[[Prototype]]
(在标准规范中)属性来连接。当访问一个对象的属性或方法时,如果在该对象本身找不到,就会到它的原型对象上去找,依次类推,形成一个原型链。可以通过Object.create()
方法创建一个具有指定原型的对象,也可以通过修改对象的原型属性来实现继承关系。 - 类与实例:在 ES6 之后,JavaScript 引入了
class
的概念,使得面向对象编程更加规范。虽然本质上还是基于原型继承,但class
语法让代码看起来更像传统的面向对象语言。例如,可以定义一个class
,里面有constructor
(构造函数)、成员方法等,然后通过new
关键字创建该class
的实例,实例可以访问class
中的方法和属性。
DOM 操作
- DOM 树与元素访问:JavaScript 可以通过
document
对象来访问和操作 HTML 文档中的元素。HTML 文档被构建成一个 DOM 树,每个元素对应一个 DOM 节点。可以通过document.getElementById()
、document.getElementsByTagName()
、document.getElementsByName()
等方法来获取特定的 DOM 元素。例如,通过document.getElementById("myId")
可以获取一个具有特定id
的元素。 - 元素操作:一旦获取到 DOM 元素,就可以对其进行各种操作,如修改元素的属性(如
element.setAttribute("class", "newClass")
)、修改元素的内容(如element.innerHTML = "新内容"
)、添加或删除元素(如element.appendChild(newElement)
、element.removeChild(oldElement)
)等。这些操作可以使网页内容发生变化,实现各种交互功能。
JavaScript 是一门功能强大且应用广泛的编程语言,在前端开发领域起着至关重要的作用。
关于秒杀项目的,开始后台给前台返回一个倒计时时间,比如页面倒计时还有 10 分钟开启秒杀,但是我手机放在那个页面很长时间不管的话,结果发现页面时间和后台时间不同步了,可能原因是什么?可以用什么方法解决?
在秒杀项目中出现页面时间和后台时间不同步的情况,可能有以下几种原因:
网络延迟
- 原因分析:网络状况可能不稳定,导致数据传输出现延迟。当后台发送倒计时更新信息给前台时,由于网络延迟,前台可能不能及时收到这些更新,从而使得页面时间与后台时间逐渐产生偏差。例如,后台每隔一分钟发送一次新的倒计时时间,但因为网络不好,前台可能几分钟后才收到某一次的更新,这样就会导致时间不同步。
- 解决方法:可以采用一些网络优化措施。比如,在前端设置合理的网络请求超时时间,当超过这个时间还未收到后台的更新数据时,可以尝试重新发送请求。同时,后台也可以采用合适的网络传输协议,如采用更稳定的 HTTP/2 协议,提高数据传输的稳定性和速度。另外,在前端请求数据时,可以增加重试机制,当一次请求失败时,可以进行若干次重试,以确保能及时收到后台的更新数据。
前端计时误差
- 原因分析:前端通过 JavaScript 来实现倒计时功能,而 JavaScript 的计时并不是绝对准确的。由于浏览器的运行环境、设备性能等因素的影响,JavaScript 计时可能会出现一定的误差。比如,在一些低性能的手机上,或者当浏览器同时运行多个复杂任务时,计时的精度可能会受到影响,导致页面时间与后台时间不一致。
- 解决方法:可以采用更精准的计时方法。例如,使用
performance.now()
函数来替代普通的Date.now()
函数进行计时。performance.now()
提供了更精细的时间测量,它返回的是一个高精度的时间戳,以微秒为单位。通过这种方式,可以提高前端计时的精度。另外,在前端实现倒计时功能时,可以定期与后台时间进行核对。比如,每隔一段时间(如每隔 30 秒),通过向后台发送一个请求,询问当前的准确时间,然后根据后台返回的时间对页面倒计时进行调整,以保证页面时间与后台时间的同步。
后台时间更新频率
- 原因分析:后台发送倒计时更新信息的频率可能不够高。如果后台只是每隔很长时间才发送一次新的倒计时时间,而在这段时间内,前台的计时可能会因为各种因素(如上述的网络延迟、前端计时误差)而出现偏差,从而导致页面时间和后台时间不同步。
- 解决方法:适当提高后台发送倒计时更新信息的频率。例如,将原来每隔 5 分钟发送一次更新,改为每隔 1 分钟发送一次更新。这样可以使前台能更及时地收到后台的更新信息,减少因为时间间隔过长而产生的时间偏差。同时,结合前面提到的前端定期与后台时间核对的方法,可以更好地保证页面时间与后台时间的同步。
页面刷新或切换
- 原因分析:如果用户在倒计时过程中对页面进行了刷新或者切换到其他页面再回来,可能会导致计时出现问题。当页面刷新时,前端的计时状态可能会被重置,需要重新从后台获取倒计时时间开始计时。而切换页面再回来时,也可能因为页面重新加载等原因,使得计时与后台时间不一致。
- 解决方法:可以在页面刷新或切换回来时,立即向后台发送请求,重新获取最新的倒计时时间,然后重新开始计时。同时,在前端可以设置一些状态保存机制,比如将当前的计时状态(如已经过去的时间、剩余的时间等)存储在本地存储(如 LocalStorage)中,当页面刷新或切换回来时,先从本地存储中获取计时状态,再结合从后台重新获取的时间,进行合理的计时调整,以保证页面时间与后台时间的同步。