极意 · 代码性能优化之道

lxf2023-03-12 12:09:01

代码敲久了,就会对代码的质量格外关注,这里总结了一些在开发中遇到的对代码性能优化的技巧及相关的原理的阐述(主要基于 v8 引擎)。

虽然本篇文章主要是分享一些对代码性能提升的写法和方式,但是请注意,不要为了纯粹追求性能而舍弃了代码的可读性和可维护性。除非你是开源的框架维护者,可能会对性能有着比较极致的追求。但如果是在做业务开发,请务必以可维护性为第一范式。

所以再次提醒:所有的质量都是建立在可读性和可维护性之上的,在保证可维护性的基础上建设高质量高性能的代码,才是代码的最佳实践

1、非响应式赋值

在 vue2 的单文件组件中,有时候会想定义一个模板或者方法中都能使用的常量,很多人会习惯性地将它定义在 data 中:

<template>
  {{ rule }}
</template>

<script>
  export default {
    data () {
      return {
        rule1: {
          name: 'rule1'
        }
      }
    },
    methods: {
      createRule() {
        if(this.rule1.name === 'rule1') {
          ...
        }
      }
    }
  }
</script>

vue2 在初始化阶段,会对 data 中定义的变量进行响应式追踪。也就是大家耳熟能详的响应式原理:通过 Object.defineProperty对 data 中定义的每个属性变量进行gettersetter的处理。如果是嵌套对象,还会对它进行递归处理。

所以当 data 中定义的变量越多时,就会对当前组件的初始化带来越重的性能开销。

如果变量仅是作为全局共享的常量,而并不需要响应式,可以直接在 created 中直接使用 this 进行定义,如下所示:

<template>
  {{ rule }}  // 模板中可以正常使用
</template>

<script>
  export default {
    data () {
      return {} // 注意这里不要定义了
    },
    created() {
      // 将变量绑定在当前组件的 this 中即可做到整个单文件组件全局共享
      this.rule1 = {
        name: 'rule1'
      }
    },
    methods: {
      createRule() {
        // 方法中可以正常使用
        if(this.rule1.name === 'rule1') {
          ...
        }
      }
    }
  }
</script>

2、避免对象属性变更

Chrome 是使用 v8 引擎对 JavaScrip进行处理的,V8 在将解释后的 JavaScript 代码编译为实际的机器码时会利用“隐藏类”。运行期间,V8 会将创建的对象与隐藏类关联起来,以追踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,v8 会针对这种情况去优化。

结合示例来看看什么是隐藏类:

在声明一个对象时

const obj = {}

v8会创建与这个对象关联的隐藏类 C01

极意 · 代码性能优化之道

当给这个对象动态添加属性时

obj.name = 'Jason'

v8 会给这个对象添加一个新的隐藏类,并从之前的隐藏类C01中继承所有属性

极意 · 代码性能优化之道

这将允许编译器在访问属性名称时绕过字典查找,并且 v8 将直接指向 C01类。

如果再次向该对象添加属性,则会发生相同的过程:创建新隐藏类,并具有以前的和新的属性作为偏移量:

obj.age = 20

极意 · 代码性能优化之道

这个隐藏类的概念不仅可以绕过字典查找,还允许在创建或修改类似对象时重用已创建的类

比如,我再创建一个新的空对象:

const obj2 = {}

此时V8 不会重复创建一个新的隐藏类了,而是直接复用隐藏类C01:

极意 · 代码性能优化之道

当给 obj2 添加新的属性时(不同于 obj),才会创建新的隐藏类,比如:

obj2.time = '2022'

这里可以看出,隐藏类的特性

  • 动态增删对象属性,会导致隐藏类的同步增删
  • 不同对象如果具有的属性一致,可以共享隐藏类,避免重复创建

所以优化思路就很明显了:

我们要避免”先创建再补充“式的动态属性复制以及动态删除属性(使用delete关键字)。即尽量在构造函数/对象中一次性声明所有属性。属性删除时可以设置为 null,这样可以保持隐藏类不变和继续共享

常见于我们调用后台接口根据不同条件向传参的时候,对参数属性的动态变更:

// × bad
const params = {
  a: 1,
  b: 2,
  c: 3
}
if(!this.cIsNeeded) delete params.c
if(this.dNeeded) params.d = 4

// √ good
const params = {
  a: 1,
  b: 2,
  c: this.cIsNeeded ? 3 : null,
  d: this.dNeeded ? 4 : null
}

在创建新对象时,可以尽量和已有对象属性顺序保持一致,达到共享隐藏类的目的

const obj = { name: 'zhangsan' } // 隐藏类C01创建
obj.age = 20  // 隐藏类 C02 创建

// × bad
const obj2 = { age: 3 } // 隐藏类 C03 被创建
obj2.name = 'lisi'  // 隐藏类 C04 被创建

// √ good
const obj2 = { name: 'lisi' } // 共享 obj 的隐藏类 C01
obj2.age = 3

3、巧用短路运算

逻辑运算符&&|| 具有短路规则,所以当我们使用他俩在进行判断的时候,可以将运算更简单部分放在前面,示例

// √ good
<div v-if="arr.length || rules.find(i => i.name === '显示')">
  需不需要显示
</div>

// × bad
<div v-if="rules.find(i => i.name === '显示') || arr.length">
  需不需要显示
</div>

因为||运算只要其中之一为 true,则为 true,当第一个条件为 true 时,就不会继续去计算第二个条件的值了,在一定场景下,是可以减少掉后半部分运算的。

同理&&运算只要其中之一为 false 则为 false,可以将简单运算放置前面

// √ good
<div v-if="arr.length && rules.find(i => i.name === '显示')">
  需不需要显示
</div>

// × bad
<div v-if="rules.find(i => i.name === '显示') && arr.length">
  需不需要显示
</div>

本条在特定场景可能才有一定的优化效果,这里仅提供一种思路,请大家酌情选用哈

4、computed 延迟计算 + 缓存

computed 和普通的 data 中定义的响应式变量从实现原理来说没有太大区别,只是多了两个功能:

  • 延迟计算

    computed 中定义的变量只有在用到这个变量的时候才会去执行计算

  • 缓存

    如果计算属性中收集的依赖没有产生变化,再次读取就不会重复计算,而是取上一次计算结果

所以在遇到需要通过一定逻辑判断或者计算的响应式变量,就可以优先使用 computed 处理

将示例 2 进一步地进行优化:

<div v-if="isNeedShow">
  需不需要显示
</div><script>
  export default {
    computed: {
      isNeedShow() {
        return this.arr.length || this.rules.find(i => i.name === '显示')
      }
    }
  }
</script>

5、避免副作用的负面影响

函数副作用是指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量、修改参数等。举俩个例子

例子1

let obj = { x: 1 }
function foo() {
    obj.x = 2
}
foo()
console.log(obj)  // { x: 2 }

foo 的执行,造成了全局变量 obj 的变化。

例子2

function bar(obj) {
    obj.x = 2
}

function foo() {
  let obj = { x: 1 }
  bar(obj)
  console.log(obj)
}
foo()  // { x: 2 }

bar 函数的执行,造成 foo 中的变量被更改。

这里需要强调的是:函数副作用并不是一个不好的东西,比如 vue3 的响应式实现使用副作用渲染函数替代了 vue2 的 watcher,我们需要注意的不是禁止使用副作用,而是避免使用副作用函数的过程中可能带来的一些不好的影响

避免全局变量

全局变量是在脚本中的任何函数之外声明或定义的变量。 这表明可以从特定脚本中的任何位置访问全局变量,而不仅限于函数或块。 JavaScript 全局变量也可以在函数或块中声明,然后可以从前面提到的任何地方访问。 比如可以通过 window 对象定义全局变量。

全局变量的优势很明显,可以做到全局共享,但是因为它共享的特性,在一个复杂系统上,我们很难去追踪是哪个函数将它变更,或者哪些操作会对它的结果产生影响。

同时,访问全局作用域意味着当前函数要从自身的作用域一直找到顶层作用域,在访问全局变量会比局部变量需要更长的时间。

所以减少全局变量的使用不但对减少 bug 有帮助,还能在一定程度上提升性能。

我们可以使用以下方式对全局变量更好的处理:

  • 使用 const 和 let 声明变量,块级作用域可以很好地限制变量的作用范围和边变量提升
  • 公共变量使用全局状态管理工具管理(如 vuex / redux)
  • 尽量不使用 window 等全局对象存储变量
  • 使用命名空间避免变量冲突
  • 使用纯函数

避免直接更改参数

  • 对传参进行深拷贝

    这里是使用适当的性能去减少未知 bug 的产生,还是很划算的,如示例二可以改成:

    // × bad
    function bar(obj) {
        obj.x = 2
    }
    
    function foo() {
      let obj = { x: 1 }
      bar(obj)
      console.log(obj)
    }
    foo()  // { x: 2 }
    
    // √ good
    function bar({...obj}) {
        obj.x = 2
    }
    
    function foo() {
      let obj = { x: 1 }
      bar(obj)
      console.log(obj)
    }
    foo()  // { x: 1 }
    

6、避免强制同步布局和布局抖动

浏览器具有渲染队列机制:当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。

正常的页面渲染一般会经历:JavaScript运行 => 样式计算 => 布局 => 绘制 => 合成 几个步骤,但是 JS 可以强制将样式计算和布局提前到当前任务当中,这就是强制同步

例如:

// × bad
function test() {
  const div = document.createElement('div')
  const mainDiv = document.getElementById('main')
  mainDiv.appendChild(div)
  console.log(mainDiv.offsetHeight)
}

我们对 mainDiv 进行插入新元素之后,立即去获取它的高度, 因为获取高度必须要强制渲染引擎对插入元素后的 mainDiv 进行一次布局操作才能拿到,这样造成的性能开销很大。

所以合理的方式应该是先读取再操作,即在修改 DOM 之前读取相关的属性值:

// √ good
function test() {
  const div = document.createElement('div')
  const mainDiv = document.getElementById('main')
  console.log(mainDiv.offsetHeight)   // 先读取
  mainDiv.appendChild(div)            // 后操作
}

布局抖动,就是频繁触发强制同步布局,

示例一:

// × bad
function test() {
  for(let i = 0; i < 100; i++) {
    const div = document.createElement('div')
    const mainDiv = document.getElementById('main')
    mainDiv.appendChild(div)
    console.log(mainDiv.offsetHeight)
  }
}

解决方法也简单,就是将读写分离:

// √ good
function test() {
    // 读写分离
    const mainDiv = document.getElementById('main')
    const width = mainDiv.offsetWidth
    console.log(mainDiv.offsetHeight)
    for(let i = 0; i < 100; i++) {
        const div = document.createElement('div')
        div.innerHTML = 'haha'
        mainDiv.appendChild(div)
        console.log(width)
    }
}

示例二:

// × bad 强制刷新,会触发四次重排 + 重绘
div.style.top = div.offsetTop + 1 + 'px';
div.style.bottom = div.offsetBottom + 1 + 'px';
div.style.left = div.offsetLeft + 1 + 'px';
div.style.right = div.offsetRight + 1 + 'px';

// √ good 先读后写 + 读写分离:缓存布局信息,仅会触发一次重排 + 重绘
const curTop = div.offsetTop;
const curBottom = div.offsetBottom;
const curLeft = div.offsetLeft;
const curRight = div.offsetRight;

div.style.left = curLeft + 1 + 'px';
div.style.top = curTop + 1 + 'px';
div.style.right = curRight + 1 + 'px';
div.style.bottom = curBottom + 1 + 'px';

总结一下:

  • 对 DOM 的操作应该是先读取再操作
  • 避免频繁的对 DOM 元素读、写、读、写,要将读和写进行分离

7、批量操作 DOM

再重复描述一遍浏览器的渲染队列机制:当我们修改了元素的几何属性,导致浏览器触发重排或重绘时。它会把该操作放进渲染队列,等到队列中的操作到了一定的数量或者到了一定的时间间隔时,浏览器就会批量执行这些操作。

所以如果同时插入大批量的 DOM 节点必定会引发多次回流,我们可以使用documentFragment来解决:

DocumentFragment文档片段接口,表示一个没有父对象的最小文档对象。

它被作为一个轻量版的 [Document]使用,就像标准的 document 一样,存储由节点(nodes)组成的文档结构。与 document 相比,最大的区别是它不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的回流,且不会对性能产生影响

就是面试官常问的,如何优雅地一次性插入2万个div:

// × bad
const app = document.getElementById('app')
for(let i = 0;i < 20000;i++) {
  const div = document.createElement('div')
  app.appendChild(div)
}

// √ good
const app = document.getElementById('app')
const fragment = document.createDocumentFragment()
for(let i = 0;i < 20000;i++) {
  const div = document.createElement('div')
  fragment.appendChild(div)
}
app.appendChild(fragment)

使用documentFragment只有一次回流,能够极大地提升性能。

8、v-if 和 v-for 不同时使用

这条是大家耳熟能详的官方文档给出的规范。在 vue2 和 vue3 中背后的原因却不太一样

  • vue2 中 v-for 的优先级高于 v-if,所以会先渲染 v-for 遍历后的所有节点,然后再根据 v-if 判断条件将不符合条件的节点干掉。DOM 节点的创建和删除带来的性能开销十分大,所以不推荐这种用法。(vue 的核心实现中一直强调:尽最大可能减少直接对 dom 的创建和删除,比如 vue 的 diff 算法就是为了尽量复用 dom,所以通过 patch 打补丁的方式对已有 dom 属性进行更新。由此可见 dom 的创建和删除对性能的影响是很大的。)

  • vue3 与 vue2 不同,vue3 中 v-if 的优先级要高于 v-for,如果判断条件依赖于 v-for 遍历的项,就会出现问题,比如:

    const list = reactive([1, 2, 3, 4, 5, 6])
    
    <div v-for="i in list" v-if="i % 2 === 0" >main</div>
    

    控制台会有警告,拿不到 i 这个变量,同时页面没有正常渲染:

    [Vue warn]: Property "i" was accessed during render but is not defined on instance. 
    

    那么如果 v-if 不和遍历项有关联呢?试一试:

    const showEle = ref(true)
    const list = reactive([1, 2, 3, 4, 5, 6])
    
    <div v-for="i in list" v-if="showEle">main</div>
    

    使用模板编译工具看看编译后的产物:

    export function render(_ctx, _cache, $props, $setup, $data, $options) {
      return (_ctx.showEle)
        ? (_openBlock(true), _createElementBlock(_Fragment, { key: 0 }, _renderList(_ctx.list, (i) => {
            return (_openBlock(), _createElementBlock("div", null, "main"))
          }), 256 /* UNKEYED_FRAGMENT */))
        : _createCommentVNode("v-if", true)
    }
    

    可以看到是先去判断 v-if 绑定的值再去渲染的。

    所以从理论上来说,vue3 当 v-if 中的条件变量与 v-for 遍历的变量无关时,同时写并不会造成类似 vue2 的性能问题。

    但是从风格规范来说,为了防止它出现上面提到的不能正常渲染问题,是不建议写在一块的。

9、getElementsByTagName 比 querySelectorAll 快

当需要通过标签去获取文档中指定的元素列表时,我们可以使用上面两种方式,示例:

const domList1 = document.getElementsByTagName('p')

const domList2 = document.querySelectorAll("p")

可以打印下结果看看:

极意 · 代码性能优化之道

不难发现,getElementsByTagName返回的是 HTMLCollection 集合,而querySelectorAll返回的是一组NodeList

getElementsByTagName返回的是实时的 NodeList,而querySelectorAll返回的是静态的 NodeList(可以看作对返回结果的 NodeList 的克隆/快照)

HTML DOM 中的 HTMLCollection 是即时更新的(live);当其所包含的文档结构发生改变时,它会自动更新。因此,最好是创建副本(例如,使用 Array.from)后再迭代这个数组以添加、移动或删除 DOM 节点。

而浏览器可以更快地创建和返回实时 NodeList 对象,因为它们不必预先拥有所有信息,而静态 NodeList 需要从一开始就拥有所有数据。为了强调这一点,WebKit 源代码为每种类型的 NodeList 提供了一个单独的源文件:DynamicNodeList.cpp 和 StaticNodeList.cpp。这两种对象类型以完全不同的方式进行创建。

DynamicNodeList 对象是通过在缓存中注册其存在来创建的。从本质上讲,创建一个新的 DynamicNodeList 的开销非常小,因为它不需要预先做任何工作。每当访问 DynamicNodeList 时,它必须查询文档的更改,如 length 属性和 item() 方法(与使用括号表示法相同)所证明的。

而对于 StaticNodeList 对象而言,它们的实例是在另一个文件中创建的,然后用循环内的所有数据进行填充。对文档运行查询的前期成本比使用 DynamicNodeList 实例时高得多。

所以getElementsByTagName()querySelectorAll()快的真正原因是由于实时和静态 NodeList 对象之间的差异。

总结:如果只是按标签名称搜索元素并且不需要快照,则应该使用 getElementsByTagName();如果需要结果快照或者正在执行更复杂的 CSS 查询,则应使用 querySelectorAll()

10、避免内存泄露

清除定时器

setInterval为什么要及时清除?

setInterval()要及时清除的根本原因是因为 setInterval通过闭包引入了外部变量,只要定时器一直执行,引用的变量就会一直占用内存,而无法进行垃圾回收。比如

let name = 'Jake';
setInterval(() => {
  // 定时器不销毁,name就得不到释放
  console.log(name);
}, 100);

所以可以在使用时通过全局变量接收定时器返回的 id ,在不需要的时候或者页面销毁的时通过 clearInterval手动释放:

this.timer = setInterval(() => {
  this.handleMission()
}, 1000)

destroyed() {
  clearInterval(this.timer)
}

PS: 针对定时器时间不准确的问题,建议使用 web worker 给定时器单独的线程运行,就不会受主线程影响了

setTimeout用完需要清除吗?

不需要。看网上争论很多,其实通过查阅 html 标准规范就能解开这个疑惑HTML Standard

我从规范中提取出清除定时器的三个相关实现:

  • If previous handle was provided, let handle be previous handle; otherwise, let handle be an implementation-defined integer that is greater than zero that will identify the timeout to be set by this call in the list of active timers. The clearTimeout() and clearInterval() methods must clear the entry identified as handle from the list of active timers of the WindowOrWorkerGlobalScope object on which the method was invoked, if any, where handle is the argument passed to the method. (If handle does not identify an entry in the list of active timers of the WindowOrWorkerGlobalScope object on which the method was invoked, the method does nothing.)
  • Let method context proxy be method context if that is a WorkerGlobalScope object, or else the WindowProxy that corresponds to method context.If previous handle was provided, let handle be previous handle; otherwise, let handle be an implementation-defined integer that is greater than zero that will identify the timeout to be set by this call in the list of active timers.
  • Once the task has been processed, if the repeat flag is false, it is safe to remove the entry for handle from the list of active timers (there is no way for the entry's existence to be detected past this point, so it does not technically matter one way or the other).

简单翻译一下就是:

  1. 定时器在初始化阶段,会给当前定时器生成一个大于零的整型数字加入到激活的定时器列表(list of active timers)当中。
  1. 当定时器中的任务执行完之后,会直接将对应的数字从激活列表中移除。
  1. clearTimeout能且只能对存在于激活列表中的定时器进行取消操作,当传入的参数不在激活列表当中时,什么也不会执行,也不会报错

所以setTimeout执行完成之后就已经被移除激活列表了,使用 clearTImeout什么都不会执行,也就没有必要多此一举地清除了。

避免使用闭包

闭包的本质就是引用了其它函数作用域中变量的函数

在 v8 的垃圾回收策略中,对存在老生代中的对象是使用的标记清除 + 标记整理的回收方式。

新生代:大多数的对象开始都会被分配到这里,这个区域相对较小但是垃圾回收特别频繁

老生代:当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会从新生代直接转移到老生代中

标记清除的实现主要是判断某个对象是否可以被访问到,从而得知该对象是否应该被回收。

标记整理是回收过程中将死亡对象清除后,在整理的过程中,会将活动对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存

所以使用闭包容易造成的后果就是,外部引入的变量迟迟得不到回收。比如

let outer = function() { 
    let name = 'Jason';  
    return function() {
        return name;
    };
};

调用outer()会导致分配给 name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 是一个很大的对象,那就是个大问题了。

所以在业务开发场景,我们要尽量避免闭包的使用。

参考

浏览器工作原理与实践

Avoid large, complex layouts and layout thrashing

Why is getElementsByTagName() faster than querySelectorAll()?

DocumentFragment - Web API 接口参考 | MDN

HTML Standard

Secret Behind JavaScript Performance: V8 & Hidden Classes | by Chameera Dulanga | Bits and Pieces

《JavaScript高级程序设计