欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

前端性能优化之路-数据存取小结

程序员文章站 2024-03-17 20:04:28
...

接着上一节讲的,我们说到过,性能优化的一大痛点就是IO读写,这一次我们讨论一下,数据读写的优化,数据存储的位置,介质决定了读取的速度。

主要指的是,应用内存(运行时内存),远程内存(redis等),本地文件系统(localstorage),远程文件系统(数据库等),这其中内存,还是文件系统,还有可能不同,内存可能有堆内存,栈内存,不同文件系统读取查找的算法,也会有相应影响,这里我们主要讨论前端,先介绍一些概念。

变量类型

字面量:
字面量只代表本身,不存储在特定的位置,主要有,字符串,数字,布尔值,对象,数组,函数,函数表达式,null,undefined
原始类型:
Undefined、Null、Boolean、Number 和 String,存储在栈中
引用类型:
主要是值对象,也就是通过new关键词创造出来的。指对象,在栈中存放引用地址,实际内存在堆中。原面说的其中三种原始类型,也有对应的引用对象,Boolean对象,Number对象,String对象。

作用域

作用域概念是理解javascript的关键所在,不仅仅从性能出发,还包括功能,作用域对javascript有许多影响,确定那些变量可以被函数访问,到确定this的赋值,然而要理解作用域对性能的影响,要先说一下作用域的工作原理。

作用域链

(出自高性能javascript)
每一个javascript的函数都是一个对象,是Function对象的一个实例,Function对象和其他对象一样,拥有可以编程访问的属性,和一系列不能通过代码访问而仅供javascript引擎存取的内部属性,其中一个内部属性,[[scope]],内部属性[[scope]]包含了一个函数创建的作用域中对象的集合,这个集合被称为函数的作用域链,他决定了那些函数能被函数访问,函数的作用域中的每个对象被成为一个可变对象,每个可变对象都以键值对存在,他的作用域链会被创建此函数的作用域中可访问的数据对象所填充

这是个比较难懂的概念,我们通过一些图形来理解。

解析一下该图,
首先我们通过

function a(num1,num2){
    var sum = num1 + num2
    console.log(sum)
}

的方式声明了一个函数,我们上面说过了,函数不是一个原始类型,是一个引用类型,是Function的一个实例,那么在栈中存在一个地址,指向了堆里的一个内存,每个对象有一个内部属性,[[scope]],指向了他的作用域链,这是function a(){}这句话执行完后,作用域链的内存结构。
前端性能优化之路-数据存取小结

第二步,我们执行a(1,2)这个函数的时候,当函数执行过程中,也会创建一个执行环境(执行上下文)的内部对象,一个执行环境对应一个了函数执行时的环境,函数每次执行时,执行环境都是独一无二的,所以多次调用同一个函数,会导致创建多个执行环境,执行环境在函数执行完毕后会消失。
而每个执行环境都有自己的作用域链,用于解析标识符,当执行环境被创建时,他的作用域链初始化为当前运行函数的[[scope]]属性中的对象,这些值按照他们出现在函数中的顺序,被复制到执行环境的作用域链中,这个过程一旦完成,一个被称为‘活动对象’的新对象就为执行环境创建好了,活动对象作为函数运行时的变量对象,包含了所有的局部变量,命名参数,参数集合以及this,然后这个对象被推入作用域链的最前端,当执行环境被销毁,活动对象也随之被销毁。

另外一个概念就是执行环境是栈内存中的,当我们script标签执行的时候,会有一个全局的执行环境在栈中,然后在其中执行到一个函数的时候,这个函数的执行环境会被压入栈顶,当这个函数中又有执行某个函数时,被执行的函数的执行环境被压入栈顶,也就是说栈顶永远是最新的函数执行环境。

前端性能优化之路-数据存取小结

然后函数在执行的过程中,每遇到一个变量,都会经历一次标识符解析过程以决定从哪里获取或者存储数据,该过程就是搜索执行环境的作用域链,查找同名的标识符。搜索过程中,从作用域链头部开始,也就是当前运行函数的活动对象,如果找到,就用这个,如果没找到,就搜索下一个对象,搜索过程会持续进行,若无法找到,则被视为是undefined,在函数执行过程中,每个标识符都会被搜索,就是这个搜索过程影响了性能,说了这么多,总算绕回来了,另外提一句,如果两个标识符的名字一样,会以先找到的那个为准,也就是说第一个遮挡了第二个。

标识符解析的性能

刚刚说了,搜索的过程就是影响性能的关键,其实不管是什么计算机操作,都会产生性能开销,在执行环境的作用域链中,一个标识符隐藏的位置越深,读写就越慢,因此在作用域链的越前端,越快,全局变量总是在最慢的。

举个简单的例子

function bindEvent(){
    while(i<10){
        document.getElementByid('btn'+i).onclick = funciton(){}
    }
}

这里document被用了10次,每次都会去经过一次完整的搜索,

function bindEvent(){
    let doc = document
    while(i<10){
        doc.getElementByid('btn'+i).onclick = funciton(){}
    }
}

简答改一下,这样,就只搜索了一次。当你的应用越大,数据库操作越多,性能优势自然越明显。

存储介质

这个的影响就不多说了,比较简单,localstorage这样的,Cookie持久化的,这些都是写到本地文件了,当你要去读的时候,首先要找到这个文件,然后从这个文件里面找到你要读取的内容,与内存中数据速度的区别就在于说,一个是存储在你大脑中,你可以直接想到,无法是想的时候,需要搜索你的大脑,而文件是你要搜索你的大脑,是放到什么文件里了,搜索到之后,去找到这个文件,把这个文件打开,阅读找到你要找的内容,两者的差距自然而语。

改变作用域链

  1. with(这个关键词,现在基本弃用了,就不做过多介绍了,有兴趣自己百度吧)
  2. tru catch,当在try中的语句执行发生异常的时候,执行过程会跳转到catch子句中,然后把异常对象推入到作用域链的顶部

动态作用域

  1. eval,同样可以动态指定变量,导致实际的变量名无法预测,通常非必要不建议使用。

闭包,作用域,内存

闭包可以说是javascript的一个强大特性,它允许函数访问局部作用域之外的数据,如今这个特性被广泛使用,然而,这个特性确有的性能问题。我们举两个闭包的场景。

function bindEvents(){
    var id = 'dom1'
    docuement.getElementById('btn').onclick = function(){
        triggerEvent('dom1')
    }
}
function fn1(){
    var id = 1
    return function(){
        console.log(id)
    }
}

bindEvents执行之后,会形成一个dom事件,当dom事件触发的时候,却能访问bindEvents函数执行之时执行环境的局部变量,这个时候bindEvents已经执行完,根据前面的介绍,这个时候执行环境消失了,但是dom事件却依然可以访问id这个变量,这个就是闭包,这个闭包能访问变量,必然是有原因的,我们通过内存图看一下。
前端性能优化之路-数据存取小结

可以看到,闭包的[[scope]]中,把它的上层函数的活动对象给保存下来了,所以在闭包执行的时候,闭包自己的执行环境又会创建一个活动对象,在原来的活动对象的上方,看到这里,大家看到问题所在了嘛,也就是说闭包的[[scope]]是保存了应该消失的执行环境内存,那么就增大了内存的开销,但是对性能的影响呢?
我们再来看,
前端性能优化之路-数据存取小结

结合图片和函数内容可以看到,在闭包执行的时候,会去访问两个标识符,一个是trggerEvent,和id,这两个标识符都在作用域链的后面位置,那么根据前面说的,每次访问的时候,都会去搜索查找,带来性能的消耗,所以跨作用域访问标识符会有性能损失,还会有内存问题。

当然我们可以通过前面说过的,将跨作用域的变量保存在局部,来减少消耗,不过如果只有一个读取的话,这个消耗就忽略不计了。

前面我们说到,访问对象成员的属性和方法比访问字面量的速度要慢,那么是为什么,这里也讨论一下,又要引入一些概念,

原型

JavaScript的对象是基于原型的,原型是所有对象的基础,它定义了并实现了一个新创建的对象所必须包含的成员列表,

对象通过一个内部属性[[proto]]绑定到它的原型,

var person = {
    name:'satisfy',
    sex:'man'
}

上述是一个对象,他是一个Object的实例,所以的它的[[proto]]就是Object.prototype
前端性能优化之路-数据存取小结

从图中可以看出,我们如果要访问对象的一个属性或者方法,它的解析过程和之前分析的变量解析过程是类似的,一个是在作用域链中找,一个是先在自己本身找,然后去原型上找,既然如此,就会有搜索的过程,就会有性能的消耗。

原型链

function Person(name,sex){
    this.name = name
    this.sex = sex
}
Person.prototype.say = function(){
    console.log(this.name)
}

var p1 = new Person('1','man')
var p2 = new Person('2','woman')

上述代码执行完成后,内存图如下:
前端性能优化之路-数据存取小结

我们从上图来分析原型链,
1. Person对应的绿色的线,是function本身的原型链
2. 红色的部分是Person实例的原型链,
3. 黑色部分是Person自己在内存中的对应情况

这个两个Person实例共享一个原型,他们有着各自的属性,也有相同的方法与属性,原型链上的所有的属性与方法都是可以供实例访问的,层级越深,找到它就越慢,不过随着javascript引擎的优化,这个速度代价已经越来越小了,但是这个代价是不能忽略的。

另外提一个,原型链上一定都是对象,原型链的终点是Object.prototype.proto == null,为什么是null,首先原型链上只能是对象,而null又是一个特殊的对象,表示没有指向任何内存的对象。

嵌套成员

因为对象实例还可能有对象,javascript引擎每次遇到点操作符,都会去搜索所有的成员来寻找,所以像这个操作
window.location.href
location.href
前者比后者就要费性能。

另外,大部分浏览器中,点表示法(object.name),扣号表示法(object[‘name’])没有明显区别,但是在safari中,点始终更快,不过这个可以忽略不计

缓存对象成员

同作用域链的原理,我们应该避免使用成员对象,同一个代码中,有一个对象属性被访问多次,我们应该避免使用每次都去查找,把他缓存在局部变量中,然后后面去使用该变量,来减少次数。

function(){
    var obj = {
        name:'name'
    }
    for(var i=0;i<10;i++){
        console.log(obj.name)//每次都要去查找
        var name = obj.name
        console.log(name)//只查找了一次
    }
}

总结

  1. 访问字面量和局部变量的速速最快,访问数组和对象速度相对较慢
  2. 由于局部变量存于作用域链的起始位置,因此访问局部变量比访问跨作用域链变量更快,变量在作用域链的位置越深,访问时长越长,由于全局变量总处在作用域链的最末端,因此访问速度也是最慢了。
  3. 避免使用with,try-catch要小心使用
  4. 嵌套对象的成员访问会影响性能,尽量少用
  5. 属性或者方法在原型链中的位置越深,访问越慢
  6. 通常来说,可以把常用访问的对象成员,数组元素,跨域变量保存在局部变量中来改善javascript性能,因为局部变量访问最快

上述全部遵循,可以大大提供大型web项目中javascript性能。