翼度科技»论坛 编程开发 JavaScript 查看内容

代码层面探索前端性能

7

主题

7

帖子

21

积分

新手上路

Rank: 1

积分
21
前言

最近在做性能优化,具体优化手段,网上铺天盖地,这里就不重复了。
性能优化可分为以下几个维度:代码层面、构建层面、网络层面。
本文主要是从代码层面探索前端性能,主要分为以下 4 个小节。

  • 使用 CSS 替代 JS
  • 深度剖析 JS
  • 前端算法
  • 计算机底层
使用 CSS 替代 JS

这里主要从动画和 CSS 组件两个方面介绍。
CSS 动画

CSS2 出来之前,哪怕要实现一个很简单的动画,都要通过 JS 实现。比如下面红色方块的水平移动:

对应 JS 代码:
  1. let redBox = document.getElementById('redBox')
  2. let l = 10
  3. setInterval(() => {
  4.     l+=3
  5.     redBox.style.left = `${l}px`
  6. }, 50)
复制代码
1998 年的 CSS2 规范,定义了一些动画属性,但由于受当时浏览器技术限制,这些特性并没有得到广泛的支持和应用。
直到 CSS3 的推出,CSS 动画得到了更全面地支持。同时,CSS3 还引入了更多的动画效果,使得 CSS 动画在今天的 Web 开发中得到了广泛的应用。
那么 CSS3 都能实现什么动画,举几个例子:

  • 过渡(Transition)- 过渡是 CSS3 中常用的动画效果之一,通过对一个元素的某些属性进行变换,使元素在一段时间内从一个状态平滑地过渡到另一个状态。
  • 动画(Animation)- 动画是 CSS3 中另一个常用的动画效果,其用于为一个元素添加一些复杂的动画效果,可以通过关键帧(@keyframes)来定义一串动画序列。
  • 变换(Transform)- 变换是 CSS3 中用于实现 2D/3D 图形变换效果的一种技术,包括旋转、缩放、移动、斜切等效果。
把上面的例子改写成 CSS 代码如下:
  1. #redBox {
  2.     animation: mymove 5s infinite;
  3. }
  4. @keyframes mymove
  5. {
  6.     from {left: 0;}
  7.     to {left: 200px;}
  8. }
复制代码
同样的效果,用样式就能实现,何乐而不为呢。
需要指出的是,CSS 的动画仍在不断发展和改进,随着新的浏览器特性和 CSS 版本的出现,CSS 动画的特性也在不断地增加和优化,以满足日益复杂的动画需求和更好的用户体验。
CSS 组件

在一些知名的组件库中,有些组件的大部分 props 是通过修改 CSS 样式实现的,比如 Vant 的Space组件。
Props功能CSS 样式direction间距方向flex-direction: column;align对齐方式align-items: xxx;fill是否让 Space 变为一个块级元素,填充整个父元素display: flex;wrap是否自动换行flex-wrap: wrap;再比如 Ant Design 的Space组件。
Props功能CSS 样式align对齐方式align-items: xxx;direction间距方向flex-direction: column;size间距大小gap: xxx;wrap是否自动换行flex-wrap: wrap;这类组件完全可以封装成 SCSS 的 mixin 实现(LESS 也一样),既能减少项目的构建体积(两个库的 Space 组件 gzip 后的大小分别为 5.4k 和 22.9k),又能提高性能。
查看组件库某个组件的体积,可访问连接
比如下面的 space mixin:
  1. /*
  2. * 间距
  3. * size: 间距大小,默认是 8px
  4. * align: 对齐方式,默认是 center,可选 start、end、baseline、center
  5. * direction: 间距方向,默认是 horizontal,可选 horizontal、vertical
  6. * wrap: 是否自动换行,仅在 horizontal 时有效,默认是 false
  7. */
  8. @mixin space($size: 8px, $direction: horizontal, $align: center, $wrap: false) {
  9.     display: inline-flex;
  10.     gap: $size;
  11.     @if ($direction == 'vertical') {
  12.         flex-direction: column;
  13.     }
  14.     @if ($align == 'center') {
  15.         align-items: center;
  16.     }
  17.     @if ($align == 'start') {
  18.         align-items: flex-start;
  19.     }
  20.     @if ($align == 'end') {
  21.         align-items: flex-end;
  22.     }
  23.     @if ($align == 'baseline') {
  24.         align-items: baseline;
  25.     }
  26.     @if ($wrap == true) {
  27.         @if $direction == 'horizontal' {
  28.             flex-wrap: wrap;
  29.         }
  30.     }
  31. }
复制代码
类似的组件还有 Grid、Layout 等。
再说下图标,下面是 Ant Design 图标组件的第一屏截图,有很多仅用 HTML + CSS 就可以轻松实现。

实现思路:

  • 优先考虑只使用样式实现
  • 仅靠样式满足不了,就先增加一个标签,通过这个标签和它的两个伪元素 ::before 和 ::after 实现
  • 一个标签实在不够,再考虑增加额外的标签
比如实现一个支持四个方向的实心三角形,仅用几行样式就可以实现(上面截图是 4 个图标):
  1. /* 三角形 */
  2. @mixin triangle($borderWidth: 10, $shapeColor: #666, $direction: up) {
  3.     width: 0;
  4.     height: 0;
  5.     border: if(type-of($borderWidth) == 'number', #{$borderWidth} + 'px', #{$borderWidth}) solid transparent;
  6.     $doubleBorderWidth: 2 * $borderWidth;
  7.    
  8.     $borderStyle: if(type-of($doubleBorderWidth) == 'number', #{$doubleBorderWidth} + 'px', #{$doubleBorderWidth}) solid #{$shapeColor};
  9.     @if($direction == 'up') {
  10.         border-bottom: $borderStyle;
  11.     }
  12.     @if($direction == 'down') {
  13.         border-top: $borderStyle;
  14.     }
  15.     @if($direction == 'left') {
  16.         border-right: $borderStyle;
  17.     }
  18.     @if($direction == 'right') {
  19.         border-left: $borderStyle;
  20.     }
  21. }
复制代码
总之,能用 CSS 实现的就不用 JS,不仅性能好,而且还跨技术栈,甚至跨端。
深度剖析 JS

介绍完了 CSS,再来看 JS,主要从基本语句和框架源码两个方面深入。
if-else 语句的优化

先了解下 CPU 是如何执行条件语句的。参考如下代码:
  1. const a = 2
  2. const b = 10
  3. let c
  4. if (a > 3) {
  5.     c = a + b
  6. } else {
  7.     c = 2 * a
  8. }
复制代码
CPU 执行流程如下:

我们看到,在执行到指令 0102 时候,由于不满足 a > 3 这个条件,就直接跳转到 0104 这个指令去执行了;而且,计算机很聪明,如果它在编译期间发现 a 永远不可能大于 3,它就会直接删除 0103 这条指令,然后,0104 这条指令就变成了下一条指令,直接顺序执行,也就是编译器的优化。
那么回到正题,假如有以下代码:
  1. function check(age, sex) {
  2.     let msg = ''
  3.     if (age > 18) {
  4.         if (sex === 1) {
  5.             msg = '符合条件'
  6.         } else {
  7.             msg = ' 不符合条件'
  8.         }
  9.     } else {
  10.         msg = '不符合条件'
  11.     }
  12. }
复制代码
逻辑很简单,就是筛选出 age > 18 并且 sex == 1 的人,代码一点儿问题都没有,但是太啰嗦,站在 CPU 的角度来看,需要执行两次跳转操作,当 age > 18 时,就进入内层的 if-else 继续判断,也就意味着再次跳转。
其实我们可以直接优化下这个逻辑(通常我们也是这样做的,但是可能知其然而不知其所以然):
  1. function check(age, sex){
  2.     if (age > 18 && sex ==1) return '符合条件'
  3.     return '不符合条件'
  4. }
复制代码
所以,逻辑能提前结束就提前结束,减少 CPU 的跳转。
Switch 语句的优化

其实 switch 语句和 if-else 语句的区别不大,只不过写法不同而已,但是,switch 语句有个特殊的优化,那就是数组。
参考以下代码:
  1. function getPrice(level) {
  2.     if (level > 10) return 100
  3.     if (level > 9) return 80
  4.     if (level > 6) return 50
  5.     if (level > 1) return 20
  6.     return 10
  7. }
复制代码
我们改成 switch 语句:
  1. function getPrice(level) {
  2.     switch(level)
  3.         case 10: return 100
  4.         case 9: return 80
  5.         case 8:
  6.         case 7:
  7.         case 6: return 50
  8.         case 5:
  9.         case 4:
  10.         case 3:
  11.         case 2:
  12.         case 1: return 20
  13.         default: return 10
  14. }
复制代码
看着没啥区别,其实编译器会把它优化成一个数组,其中数组的下标为 0 到 10,不同下标对应的价格就是 return 的数值,也就是:

而我们又知道,数组是支持随机访问的,速度极快,所以,编译器对 switch 的这个优化就会大大提升程序的运行效率,这可比一条一条执行命令快多了。
那么,我还写个毛的 if-else 语句啊,我直接全部写 switch 不就行了?
不行!因为编译器对 switch 的优化是有条件的,它要求你的 code 必须是紧凑的,也就是连续的。
这是为什么呢?因为我要用数组来优化你啊,你如果不是紧凑的,比如你的 code 是 1、50、51、101、110,我就要创建一个长度 110 的数组来存放你,只有这几个位置有用,岂不是浪费空间!
所以,我们在使用 switch 的时候,尽量保证_code 是紧凑的数字类型_的。
循环语句的优化

其实循环语句跟条件语句类似,只不过写法不同而已,循环语句的优化点是以减少指令为主。
我们先来看一个中二的写法:
  1. function findUserByName(users) {
  2.    let user = null
  3.    for (let i = 0; i < users.length; i++) {
  4.        if (users[i].name === '张三') {
  5.            user = users[i]
  6.        }
  7.    }
  8.    return user
  9. }
复制代码
如果数组长度是 10086,第一个人就叫张三,那后面 10085 次遍历不就白做了,真拿 CPU 不当人啊。
你直接这样写不就行了:
  1. function findUserByName(users) {
  2.     for (let i = 0; i < users.length; i++) {
  3.         if (users[i].name === '章三') return users[i]
  4.     }
  5. }
复制代码
这样写效率高,可读性强,也符合我们上述的_逻辑能提前结束就提前结束_这个观点。CPU 直接感谢你全家。
其实,这里还有一点可以优化的地方,就是我们的数组长度可以提取出来,不必每次都访问,也就是这样:
  1. function findUserByName(users) {
  2.     let length = users.length
  3.     for (let i = 0; i < length; i++) {
  4.         if (users[i].name === '章三') return users[i]
  5.     }
  6. }
复制代码
这看起来好像有点吹毛求疵了,确实是,但是如果考虑到性能的话,还是有点用的。比如有的集合的 size() 函数,不是简单的属性访问,而是每次都需要计算一次,这种场景就是一次很大的优化了,因为省了很多次函数调用的过程,也就是省了很多个 call 和 return 指令,这无异是提高了代码的效率的。尤其是在循环语句这种容易量变引起质变的情况下,差距就是从这个细节拉开的。
函数调用过程参考:

对应代码如下:
  1. let a = 10
  2. let b = 11
  3. function sum (a, b) {
  4.     return a + b
  5. }
复制代码
说完了几个基础语句,再来看下我们经常使用的框架内部,很多地方的性能都值得探索。
diff 算法

Vue 和 React 中都使用了虚拟 DOM,当执行更新时,要对比新旧虚拟 DOM。如果没有任何优化,直接严格 diff 两颗树,时间复杂度是 O(n^3),根本不可用。所以 Vue 和 React 必须使用 diff 算法优化虚拟 DOM:
Vue2 - 双端比较:

类似上面的图:

  • 定义 4 个变量,分别为:oldStartIdx、oldEndIdx、newStartIdx 和 newEndIdx
  • 判断 oldStartIdx 和 newStartIdx 是否相等
  • 判断 oldEndIdx 和 newEndIdx 是否相等
  • 判断 oldStartIdx 和 newEndIdx 是否相等
  • 判断 oldEndIdx 和 newStartIdx 是否相等
  • 同时 oldStartIdx 和 newStartIdx 向右移动;oldEndIdx 和 newEndIdx 向左移动
Vue3 - 最长递增子序列:

整个过程是基于 Vue2 的双端比较再次进行优化。比如上面这个截图:

  • 先进行双端比较,发现前面两个节点(A 和 B)和最后一个节点(G)是一样的,不需要移动
  • 找到最长递增子序列 C、D、E(新旧 children 都包含的,最长的顺序没有发生变化的一组节点)
  • 把子序列当成一个整体,内部不用进行任何操作,只需要把 F 移动到它的前面,H 插入到它的后面即可
React - 仅右移:

上面截图的比较过程如下:

  • 遍历 Old 存下对应下标 Map
  • 遍历 New,b 的下标从 1 变成了 0,不动(是左移不是右移)
  • c 的下标从 2 变成了 1,不动(也是左移不是右移)
  • a 的下标从 0 变成了 2,向右移动,b、c 下标都减 1
  • d 和 e 位置没变,不需要移动
总之,不管用什么算法,它们的原则都是:

  • 只比较同一层级,不跨级比较
  • Tag 不同则删掉重建(不再去比较内部的细节)
  • 子节点通过 key 区分(key 的重要性)
最后也都成功把时间复杂度降低到了 O(n),才可以被我们实际项目使用。
setState 真的是异步吗

很多人都认为 setState 是异步的,但是请看下面的例子:
  1. clickHandler = () => {
  2.     console.log('--- start ---')
  3.     Promise.resolve().then(() => console.log('promise then'))
  4.     this.setState({val: 1}, () => {console.log('state...', this.state.val)})
  5.     console.log('--- end ---')
  6. }
  7. render() {
  8.     return setState
  9. }
复制代码
实际打印结果:

如果是异步的话,state 的打印应该在微任务 Promise 后执行。
为了解释清这个原因,必须先了解 JSX 里的事件机制。
JSX 里的事件,比如 onClick={() => {}},其实叫合成事件,区别于我们常说的自定义事件:
  1. // 自定义事件
  2. document.getElementById('app').addEventListener('click', () => {})
复制代码
合成事件都是绑定在 root 根节点上,有前置和后置操作,拿上面的例子举例:
  1. function fn() { // fn 是合成事件函数,内部事件同步执行
  2.     // 前置
  3.     clickHandler()
  4.    
  5.     // 后置,执行 setState 的 callback
  6. }
复制代码
可以想象有函数 fn,里面的事件都是同步执行的,包括 setState。fn 执行完,才开始执行异步事件,即 Promise.then,符合打印的结果。
那么 React 为什么要这么做呢?
因为要考虑性能,如果要多次修改 state,React 会先合并这些修改,合并完只进行一次 DOM 渲染,避免每次修改完都渲染 DOM。
所以 setState_本质是同步_,日常说的“异步”是不严谨的。
前端算法

讲完了我们的日常开发,再来说说算法在前端中的应用。
友情提示:算法一般都是针对大数据量而言,区别于日常开发。
能用值类型就不用引用类型

先来看一道题。
求 1-10000 之间的所有对称数,例如:0, 1, 2, 11, 22, 101, 232, 1221...
思路 1 - 使用数组反转、比较:数字转换为字符串,再转换为数组;数组 reverse,再 join 为字符串;前后字符串进行对比。
[code]function findPalindromeNumbers1(max) {    const res = []    if (max

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x

举报 回复 使用道具