JavaScript 性能优化

332 阅读7分钟
  • 内存管理
  • 垃圾回收与常见的 GC 算法
  • V8 引擎的垃圾回收
  • Performance 工具
  • 代码优化实例

内存管理

  • 内存: 由可读写单元组成,表示一片可操作空间
  • 管理: 认为的去操作一片空间的申请、使用和释放
  • 内存管理: 开发者主动申请空间、使用空间、释放空间
  • 管理流程: 申请——使用——释放
// 申请空间

let obj = {}

// 使用空间

obj.name = 'lg'

// 释放空间

obj = null

JavaScript 中的垃圾回收

JavaScript 中的垃圾
  • JavaScript 中内存管理是自动的
  • 对象不再被引用时时垃圾
  • 对象不能从根上访问到时时垃圾
JavaScript 中的可达对象
  • 可以访问到的对象就是可达对象(引用, 作用域链)
  • 可达的标准就是从根出发是否能够被找到
  • JavaScript 中的根可以理解成 —————— 全局变量对象
JavaScript 中的 引用 和 可达
// reference

let obj = { name: 'xm' } // 引用 + 1

let ali = obj // 引用 + 1

console.log(obj) // 可达

obj = null // 由于 ali 这个变量还在引用 obj , 所以 obj 依然为 可达
function objGroup (obj1, obj2) {
  obj1.next = obj2
  obj2.prev = obj1
  return {
    o1: obj1,
    o2: obj2
  }
}

let obj = objGroup({ name: 'obj1' }, { name: 'obj2' })

console.log(obj)

截屏2021-10-11 23.42.01.png

截屏2021-10-11 23.42.25.png

从根出发,如果某个对象的访问路线全部被破坏掉,该对象就成了垃圾

GC 算法

GC 定义 与 作用

  • GC 就是垃圾回收机制的简写
  • GC 可以找到内存中的垃圾、并释放和回收空间

GC 里的垃圾是什么

  • 程序中不再需要使用的对象
function func () {
    name = 'lg'
    return name
}
func()
  • 程序中不能再访问到的对象
function func () {
    const name = 'lg'
    return name
}
func()

GC 算法是什么

  • GC 是一种机制,垃圾回收器完成具体的工作
  • 工作的内容就是查找垃圾释放空间、回收空间
  • 算法就是工作时查找和回收所遵循的规则

常见的 GC 算法

  • 引用计数
    • 核心思想: 设置引用数,判断当前引用数是否为 0
    • 引用计数器
    • 引用关系改变时修改引用数字
    • 引用数字为 0 立即回收
    • 优点
      • 发现垃圾时立即回收
      • 最大限度减少程序暂停
    • 缺点
      • 无法回收循环引用的对象
      • 时间开销大(监控对象的修改耗时)
      function fn () {
          const obj1 = {}
          const obj2 = {}
          
          obj1.name = obj2
          obj2.name = obj1
      }
      fn ()
      
  • 标记清除
    • 核心思想: 分标记和清除两个阶段完成
    • 遍历所有对象找到标记活动对象
    • 遍历所有对象清除没有标记的对象
    • 回收相应的空间 截屏2021-10-12 22.59.43.png 截屏2021-10-12 23.00.31.png
    • 优点
      • 相对引用技术来说,可以解决对象循环引用的情况如上图 a1 b1
    • 缺点 截屏2021-10-12 23.05.52.png
      • 不会立即回收垃圾对象,分两步(标记、清除)
      • 回收的垃圾片段,内存地址上不连续————空间碎片化
  • 标记整理
    • 标记整理可以看作是标记清除的增强

    • 标记阶段的操作和标记清除一致

    • 清除阶段会限执行整理,移动对象的位置

    • 优点

      • 减少碎片化空间
      • 不会立即回收垃圾对象 截屏2021-10-12 23.09.42.png

截屏2021-10-12 23.10.01.png

截屏2021-10-12 23.10.11.png

  • 分代回收

V8

  • V8 是一款主流的 JavaScript 执行引擎
  • V8 采用即时编译 (源码 -> 机器码)
  • V8 内存设限 (64: <= 1.5G, 32: <= 800M)

V8 垃圾回收策略

  • 采用分代回收的思想
  • 内存分为新生代、老生代
  • 针对不同对象采用不同算法

截屏2021-10-12 23.22.19.png

V8 中常用 GC 算法
  • 分代回收
  • 空间复制
  • 标记清除
  • 标记整理
  • 标记增量
V8 如何回收新生代对象
  • V8 内存空间一分为二
  • 小空间用于存储新生代对象 (32M | 16M)
  • 新生代指的是存活时间较短的对象
新生代对象回收实现
  • 回收过程采用复制算法 + 标记整理
  • 新生代内存区分为两个等大小空间
  • 使用空间为 From, 空闲空间为 To
  • 活动对象存储于 From 空间
  • 标记整理后将活动对象拷贝至 To
  • FromTo 交换空间完成释放
回收细节说明
  • 拷贝过程中可能出现晋升
  • 晋升就是将新生代对象移动至老生代
  • 一轮 GC 还存活的新生代需要晋升
  • To 空间的使用率超过 25% 需要晋升
V8 如何回收老生代对象
  • 老生代对象存放在右侧老生代区域
  • 64: <= 1.4G, 32 操作系统 <= 700M
  • 老生代对象就是指存活时间较长的对象
老生代对象回收实现
  • 主要采用标记清除、标记整理、增量标记算法
  • 首先使用标记清除完成垃圾回收空间的回收
  • 采用标记整理进行空间优化 (当发生晋升的时候出现空间不足)
  • 采用增量标记进行效率优化
细节对比
  • 新生代区域垃圾回收使用空间换时间
  • 老生代区域垃圾回收不适合复制算法

标记增量如何优化垃圾回收

截屏2021-10-12 23.45.32.png

Performance 工具介绍

为什么使用 Performance

  • GC 的目的是为了实现内存空间的良性循环
  • 良性循环的基石是合理使用
  • 时刻关注才能确定是否合理
  • Performance 提供多种监控方式 进行 时刻监控

内存问题的体现

  • 页面出现延迟加载或者经常性暂停
  • 页面持续性出现糟糕的性能
  • 页面的性能随时间延长越来越差

监控内存的几种方式

  • 内存泄漏: 内存使用持续升高
  • 内存膨胀: 在多数设备上都存在性能问题
  • 频繁垃圾回收: 通过内存变化图进行分析
方法
  • 浏览器任务管理器
  • Timeline 时序图记录
  • 对快照查找分离 DOM
  • 判断是否存在频繁的垃圾回收

V8 引擎工作流程

截屏2021-10-13 22.26.16.png

  • Scanner 是一个扫描器 (词法分析)
  • Parser 是一个解析器 (生成 AST)
    • PreParser 预解析器
      • 跳过未被使用的代码
      • 不生成 AST, 创建无变量引用和声明的 scopes
      • 依据规范抛出特定错误
      • 解析速度更快
    • 全量解析
      • 解析被使用的代码
      • 生成 AST
      • 构建具体 scopes 信息,变量引用、声明等
      • 抛出所有语法错误
  • Ignition 是 V8 提供的一个解释器 (AST => 字节码)
  • TurboFan 是 V8 提供的编译器模块 (字节码 => 机器码)

堆栈处理

堆栈准备

  • JS 执行环境
  • 执行环境栈(ECStack, execution context stack)
  • 执行上下文
  • VO(G) 全局变量对象
对象堆栈执行

02-对象堆栈执行.png

函数堆栈执行

03-函数堆栈执行.png

  • 函数创建
    • 可以将函数名称看做是变量,存放在VO 当中,同时它的值就是当前函数对应的内存地址
    • 函数本身也是一个对象,创建时会有一个内存地址,空间内存放的就是函数体代码 (字符串形式)
  • 函数执行
    • 函数执行时会生成一个全新的私有上下文,它里面有一个AO 用于管理这个上下文当中的变量
    • 确定作用域链 < 当前执行上下文, 上级作用域所在的执行上下文 >
    • 确定 this
    • 初始化 arguments
    • 形参赋值: 它就相当于是变量声明,然后将声明的变量放置于 AO
    • 变量提升
    • 代码执行

闭包堆栈处理

04-闭包与堆栈执行.png

  • 闭包是一种机制
    • 当前上下文中的变量与其他上下文中的变量互不干扰
    • 当前上下文中的数据(堆内存)被当前上下文以外的上下文的变量所引用,这个数据就保存下来了
    • 函数调用的时候形成了一个全新的上下文,在函数调用之后当前上下文不被释放就是闭包

闭包与垃圾回收

05-闭包与GC.png

循环添加事件的实现

  • 基础 (有一定的问题)
for (var i = 0; i < aButtons.length; i++) {
  aButtons[i].onclick = function () {
    console.log(`当前索引值为${i}`) // 永远打印 2
  }
}
  • 闭包 (性能不够好, 需要开辟大量内存空间)
for (var i = 0; i < aButtons.length; i++) {
  (function (i) {
    aButtons[i].onclick = function () {
      console.log(`当前索引值为${i}`)
    }
  })(i)
}

for (var i = 0; i < aButtons.length; i++) {
  aButtons[i].onclick = (function (i) {
    return function () {
      console.log(`当前索引值为${i}`)
    }
  })(i)
}
  • 将基础方案的var => let (其实还是要创建多个内存空间, 属于闭包的思想)
for (let i = 0; i < aButtons.length; i++) {
  aButtons[i].onclick = function () {
    console.log(`当前索引值为${i}`)
  }
}
  • 自定义属性 (减少了形成闭包的匿名函数的执行上下文的创建)
for (var i = 0; i < aButtons.length; i++) {
  aButtons[i].myIndex = i
  aButtons[i].onclick = function () {
    console.log(`当前索引值为${this.myIndex}`)
  }
}
  • 自定义属性 + 事件委托 (只需要创建一个函数的执行上下文,执行完即可释放)
document.body.onclick = function (ev) {
  var target = ev.target,
    targetDom = target.tagName
  if (targetDom === 'BUTTON') {
    var index = target.getAttribute('index')
    console.log(`当前点击的是第 ${index} 个`)
  }
}

JSBench 测试代码

变量局部化

这样可以提高代码的执行效率(减少了数据访问时需要查找的路径)

var i, str = ""
function packageDom() {
  for (i = 0; i < 1000; i++) {
    str += i
  }
}
packageDom()

function packageDom() {
  let str = ''
  for (let i = 0; i < 1000; i++) {
    str += i
  }
}

packageDom()

测试结果 截屏2021-10-14 22.47.56.png

尽可能避免跨作用域层级读取变量

缓存数据

对于需要多次使用的数据进行提前保存,后续进行使用

function hasClassName(ele, cls) {
  console.log(ele.className)
  return ele.className == cls
}
console.log(hasClassName(oBox, 'skip'))


function hasClassName(ele, cls) {
   // 假设在当前的函数体当中需要对 className 的值进行多次使用,那么我们就可以将它提前缓存起来
  var clsName = ele.className
  console.log(clsName)
  return clsName == cls
}
console.log(hasClassName(oBox, 'skip'))
  • 减少声明和语句数 (词法分析、语法分析)
  • 缓存数据 (作用域链查找更快)

减少访问层级

function Person() {
  this.name = 'zce'
  this.age = 40
}

let p1 = new Person()
console.log(p1.age)

function Person() {
  this.name = 'zce'
  this.age = 40
  this.getAge = function () {
    return this.age
  }
}

let p1 = new Person()
console.log(p1.getAge())

防抖和节流

在一些高频率的场景下, 我们不希望对应的事件处理函数多次执行

场景:

  • 滚动事件
  • 输入模糊匹配
  • 轮播图切换
  • 点击操作
  • ......

浏览器默认情况下都会有自己的监听时间间隔, 如果检测到多次时间的监听执行,那么就会造成不不必要的资源浪费

前置前景: 界面上有一个按钮,我们可以连续多次点击

防抖: 对于这个高频操作来说,我们只希望识别一次点击,可以认为是第一次或者是最后一次 节流: 对于高频操作,我们可以自己来设置频率,让本身会执行很多次的时间触发,按着我们定义的频率减少触发的次数

防抖

var oBtn = document.getElementById('btn')
    // oBtn.onclick = function () {
    //   console.log('点击了')
    // }

    /** 
     * handle 最终需要执行的事件监听
     * wait 事件触发之后多久开始执行
     * immediate 控制执行第一次还是最后一次,false 执行最后一次
    */
    function myDebounce(handle, wait, immediate) {

      // 参数类型判断及默认值处理
      if (typeof handle !== 'function') throw new Error('handle must be an function')
      if (typeof wait === 'undefined') wait = 300
      if (typeof wait === 'boolean') {
        immediate = wait
        wait = 300
      }
      if (typeof immediate !== 'boolean') immediate = false

      // 所谓的防抖效果我们想要实现的就是有一个 ”人“ 可以管理 handle 的执行次数
      // 如果我们想要执行最后一次,那就意味着无论我们当前点击了多少次,前面的N-1次都无用
      let timer = null
      return function proxy(...args) {
        let self = this,
          init = immediate && !timer
        clearTimeout(timer)
        timer = setTimeout(() => {
          timer = null
          !immediate ? handle.call(self, ...args) : null
        }, wait)

        // 如果当前传递进来的是 true 就表示我们需要立即执行
        // 如果想要实现只在第一次执行,那么可以添加上 timer 为 null 做为判断
        // 因为只要 timer 为 Null 就意味着没有第二次....点击
        init ? handle.call(self, ...args) : null
      }

    }

    // 定义事件执行函数
    function btnClick(ev) {
      console.log('点击了1111', this, ev)
    }

    // 当我们执行了按钮点击之后就会执行...返回的 proxy
    oBtn.onclick = myDebounce(btnClick, 200, false)
    // oBtn.onclick = btnClick()  // this ev

节流

// 节流:我们这里的节流指的就是在自定义的一段时间内让事件进行触发

function myThrottle(handle, wait) {
  if (typeof handle !== 'function') throw new Error('handle must be an function')
  if (typeof wait === 'undefined') wait = 400

  let previous = 0  // 定义变量记录上一次执行时的时间 
  let timer = null  // 用它来管理定时器

  return function proxy(...args) {
    let now = new Date() // 定义变量记录当前次执行的时刻时间点
    let self = this
    let interval = wait - (now - previous)

    if (interval <= 0) {
      // 此时就说明是一个非高频次操作,可以执行 handle 
      clearTimeout(timer)
      timer = null
      handle.call(self, ...args)
      previous = new Date()
    } else if (!timer) {
      // 当我们发现当前系统中有一个定时器了,就意味着我们不需要再开启定时器
      // 此时就说明这次的操作发生在了我们定义的频次时间范围内,那就不应该执行 handle
      // 这个时候我们就可以自定义一个定时器,让 handle 在 interval 之后去执行 
      timer = setTimeout(() => {
        clearTimeout(timer) // 这个操作只是将系统中的定时器清除了,但是 timer 中的值还在
        timer = null
        handle.call(self, ...args)
        previous = new Date()
      }, interval)
    }
  }

}

// 定义滚动事件监听
function scrollFn() {
  console.log('滚动了')
}

// window.onscroll = scrollFn
window.onscroll = myThrottle(scrollFn, 600)

减少判断层级

减少循环体活动

字面量与构造式

字面量 优于 构造式