前端性能优化回顾浅析

130 阅读6分钟

基础概念

回顾

雅虎前端优化 35 条军规-翻译

雅虎 14 条优化规则

  1. 减少 HTTP 请求数
  2. 使用 CDN(内容分发网络)
  3. 添加 Expire/Cache-Control 头
  4. 启用 Gzip 压缩
  5. 将 css 放在页面最上面
  6. 将 script 放在页面最下面
  7. 避免在 CSS 中使用 Expressions
  8. 把 javascript 和 css 都放到外部文件中
  9. 减少 DNS 查询
  10. 压缩 JavaScript 和 CSS
  11. 避免重定向
  12. 移除重复的脚本
  13. 配置实体标签 ETags
  14. ajax 缓存

一些补充

  1. defer async,使用 defer
  2. 懒加载 懒加载-图片和视频,用模糊的图片代替,进入视窗加载正常的图片 
let num = document.getElementsByTagName('img').length
let img = document.getElementsByTagName('img')
let n = 0 //存储图片加载到的位置,避免每次都从第一张图片开始遍历
lazyload() //页面载入完毕加载可视区域内的图片
window.onscroll = lazyload
function lazyload() {
  //监听页面滚动事件
  let seeHeight = document.documentElement.clientHeight //可见区域高度
  let scrollTop = document.documentElement.scrollTop || document.body.scrollTop //滚动条距离顶部高度
  for (let i = n; i < num; i++) {
    if (img[i].offsetTop < seeHeight + scrollTop) {
      if (img[i].getAttribute('src') == 'loading.png') {
        //data-src放的是真实地址
        img[i].src = img[i].getAttribute('data-src')
      }
      n = i + 1
    }
  }
}

IntersectionObserver
react 里组件懒加载
React.lazy() + React.Suspense 

  1. 预加载

预加载图片(代理模式)

var myImage = (function () {
  var imhNode = document.createElement('img')
  document.body.appendChild(imgNode)

  return {
    setSrc: function (src) {
      imgNode.src = src
    }
  }
})()

var proxyImage = (function () {
  var img = new Image()
  img.onload = function () {
    myImage.set(this.src)
  }
  return {
    setSrc: function () {
      myImage.setSrc('file://.../loading.gif')
      img.src = src
    }
  }
})()

proxyImage.setSrc('http://xxxx/a.jpg')

react 里预加载组件

  1. 在 React.lazy()之前加载组件
  2. 预先渲染组件 (不太好)
const stockChartPromise = import('./StockChart')
const StockChart = React.lazy(() => stockChartPromise)

4.SSR(略) 

代码

数据访问

  1. 作用域链
function add(num1, num2) {
  var sum = num1 + num2
  return sum
}

作用域链上全局的总是在最后,所以对于使用多次的局部变量,进行保存

function initUI() {
  var bd = document.body,
    links = document.getElementsByTagName('a'),
    i = 0,
    len = links.length
  while (i < len) {
    update(links[i++])
  }
  document.getElementById('go-btn').onclick = function () {
    start()
  }
  bd.className = 'active'
}

document 被多次访问, 可以改写成

function initUI() {
  var doc = document,
    bd = doc.body,
    links = doc.getElementsByTagName('a'),
    i = 0,
    len = links.length
  while (i < len) {
    update(links[i++])
  }
  doc.getElementById('go-btn').onclick = function () {
    start()
  }
  bd.className = 'active'
}

这一点改写不会立马有什么性能提升,但是当大量的代码积累起来,就不一样了

with,try catch,eval,都是动态 scope,需要注意,都会把一个 scope 推到 scope 链的最前端

  1. 原型链

原型链越深,访问越慢

function test() {
  if (window.location.href.toString() == 'xxx') {
  }

  var res = window.location.href.toString() + 'aaa'

  return res
}

更快的方式

function test() {
  var temp = window.location.href.toString()
  if (temp == 'xxx') {
  }

  var res = temp + 'aaa'

  return res
}

如果一个对象的属性在一个函数中使用两次以上,那就最好定义一个本地变量来保存。比如 toString()一般是在 Object 对象 上,如果一个函数里多次使用,就最好先定义一个 local variable 保存

dom 操作

dom 和 js 本就是独立的实现,想象两个孤岛,一个是 js 引擎,一个是 dom 引擎,两个之间有座桥,过一次桥就得花一次过路费。(chromium 计划中有个大胆的想法,把 dom 树引入 js 引擎,这会极大的提升 dom 操作性能)

  1. 单访问 dom 节点就有损耗
  2. 如果修改 dom 引发重绘重排,更加损耗性能
function innerHTMLLoop() {
  for (var count = 0; count < 15000; count++) {
    document.getElementById('here').innerHTML += 'a'
  }
}
function innerHTMLLoop2() {
  var content = ''
  for (var count = 0; count < 15000; count++) {
    content += 'a'
  }
  document.getElementById('here').innerHTML += content
}

字符串拼接的+也要注意下,在早期的浏览器里(主要是 ie)有区别的,不如用数组再 join

collection elements

// an accidentally infinite loop
var alldivs = document.getElementsByTagName('div')
for (var i = 0; i < alldivs.length; i++) {
  document.body.appendChild(document.createElement('div'))
}

元素合集的 length 是动态更新的,每次都会从 document 上重新查询,转成一个数组反而更快,或者赋值给一个局部变量

// slow
function collectionGlobal() {
  var coll = document.getElementsByTagName('div'),
    len = coll.length,
    name = ''
  for (var count = 0; count < len; count++) {
    name = document.getElementsByTagName('div')[count].nodeName
    name = document.getElementsByTagName('div')[count].nodeType
    name = document.getElementsByTagName('div')[count].tagName
  }
  return name
}
// faster
function collectionLocal() {
  var coll = document.getElementsByTagName('div'),
    len = coll.length,
    name = ''
  for (var count = 0; count < len; count++) {
    name = coll[count].nodeName
    name = coll[count].nodeType
    name = coll[count].tagName
  }
  return name
}
// fastest
function collectionNodesLocal() {
  var coll = document.getElementsByTagName('div'),
    len = coll.length,
    name = '',
    el = null
  for (var count = 0; count < len; count++) {
    el = coll[count]
    name = el.nodeName
    name = el.nodeType
    name = el.tagName
  }
  return name
}

查找元素多使用 querySelectorAll()和 querySelector()这两个方法,速度更快,效率更高

重绘重排

当浏览器加载好 html,js,css,图片后,会创建两颗树。 dom 树和渲染树

当页面元素的尺寸,边框等等变化了,就会影响渲染树对应的部分并重新构建渲染树,这就导致了重排(reflow),重排结束后,浏览器重新在屏幕上绘制影响的部分,这个过程就是重绘

回流比重绘的代价要更高。 有时即使仅仅回流一个单一的元素,它的父元素以及任何跟随它的元素也会产生回流。现代浏览器会对频繁的回流或重绘操作进行优化:浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。

当你访问以下属性或方法时,浏览器会立刻清空队列:

clientWidth、clientHeight、clientTop、clientLeft
offsetWidth、offsetHeight、offsetTop、offsetLeft
scrollWidth、scrollHeight、scrollTop、scrollLeft
widthheight
getComputedStyle()
getBoundingClientRect()

引起重排的一些操作

  1. 增删可见的 dom 元素
  2. 元素的位置改变
  3. 元素的尺寸改变
  4. 页面渲染初始化
  5. 浏览器窗口大小改变
  6. 设置 style 属性
  7. 改变文字大小
  8. 激活伪类
  9. 操作 class 属性
  10. 内容改变
  11. 添加删除样式表

正则

回溯 /h(ello|appy) hippo/.test("hello there, happy hippo");

重复与回溯

// 贪婪模式和惰性模式
var str = "<p>Para 1.</p><img src='smiley.jpg'><p>Para 2.</p><div>Div.</div>"
var reg = /<p>.*</p>/i
var reg2 = /<p>.*?</p>/i
reg.test(str)
回溯失控问题 :

举个例子:

回溯失控的时候,可能导致浏览器假死数秒、数分钟或更长时间,以下面这个正则为例(用来匹配整个 HTML 字符串):

/<html>[\s\S]*?<head>[\s\S]*?</head>[\s\S]*?<body>[\s\S]*?</body>[\s\S]*?</html>/

此正则表达式匹配在正常 HTML 字符串时工作良好,但当目标字符串缺少一个或多个标签时,就会变得十分糟糕。例如标签缺失,最后一个[\s\S]?将扩展到字符串的末尾,因为在那里没有发现标签,然后正则表达式将查看此前的[\s\S]?队列记录的回溯位置,使它们进一步扩大。正则表达式尝试扩展倒数第二个[\s\S]?—用它匹配标签,就是此前匹配过正则表达式模板的那个标签,然后继续查找第二个标签,直到字符串的末尾。当所有这些步骤都失败时,倒数第三个[\s\S]?将被扩展,直至字符串的末尾,依此类推。

回溯失控终极方案:模拟原子组(预查+反向引用):(?=([\s\S]*?<head>))\1

/<html>(?=([\s\S]*?<head>))\1(?=([\s\S]*?</head>))\2(?=([\s\S]*?<body>))\3(?=([\s\S]*?</body>))\4[\s\S]*?</html>/

原子组(向前查看)的任何回溯位置都会被丢弃,从根源上避免了回溯失控,但是向前查看不会消耗任何字符作为全局匹配的一部分,捕获组+反向引用在这里可以用来解决这个问题,需要注意的是这的反向引用次数,即上面的\1、\2、\3、\4 对应的位置。

框架

shouldComponentUpdate: 如果在这个生命周期里返回 false,就可以跳过后续该组件的 render 过程

React.PureComponent: 会对传入组件的 props 进行浅比较,如果浅比较相等,则跳过 render 过程,适用于 Class Component *

React.memo: 同上,适用于 functional Component

属性进行浅比较,如果是值类型那没问题,如果是对象,通过第二个参数进行深比较来跳过渲染

const Child = React.memo(
  function Child(props: { item: Item }) {
    console.log('render child')
    const { item } = props
    return <div>name:{item.text}</div>
  },
  (prev, next) => {
    // 使用深比较比较对象相等
    return deepEqual(prev, next)
  }
)

虽然这样能达到效果,但是深比较处理比较复杂的对象时仍然存在较大的性能开销甚至挂掉的风险(如处理循环引用),因此并不建议去使用深比较进行性能优化。

React.useMemo

function Parent() {
  const [count, setCount] = React.useState(0)
  const [name, setName] = React.useState('')
  React.useEffect(() => {
    setInterval(() => {
      setCount((x) => x + 1)
    }, 1000)
  }, [])
  const item = React.useMemo(
    () => ({
      text: name,
      done: false
    }),
    [name]
  ) // 如果name没变化,那么返回的始终是同一个 item
  return (
    <>
      <input
        value={name}
        onChange={(e) => {
          setName(e.target.value)
        }}
      />
      <div>counter:{count}</div>
      <Child item={item} />
    </>
  )
}

至此我们保证了 Parent 组件里 name 之外的 state 或者 props 变化不会重新生成新的 item,借此保证了 Child 组件不会 在 props 不变的时候重新渲染。

function Parent() {
  const [count, setCount] = React.useState(0)
  const [name, setName] = React.useState("")
  const [items, setItems] = React.useState([] as Item[])
  React.useEffect(() => {
    setInterval(() => {
      setCount(x => x + 1)
    }, 1000)
  }, [])
  const handleAdd = () => {
    setItems(items => {
      items.push({
        text: name,
        done: false,
        id: uuid(),
      })
      return items
    })
  }
  return (
    <form onSubmit={handleAdd}>
      <Row>counter:{count}</Row>
      <Row>
      <Input
          width={50}
          size="small"
          value={name}
          onChange={e => {
            setName(e.target.value)
          }}
        />
        <Button onClick={handleAdd}>+</Button>
        {items.map(x => (
          <Child key={x.id} item={x} />
        ))}
      </Row>
    </form>
  )
}

class 的 setState: 不管你传入的是什么 state,都会强制刷新当前组件 hooks 的 setState: 如果前后两次的 state 引用相等,并不会刷新组件,因此需要用户进行保证当深比较结果不等的情况下,浅比较结果也不等,否则会造成视图和 UI 的不一致。

hooks 的这个变化意味着假使在组件里修改对象,也必须保证修改后的对象和之前的对象引用不等(这是以前 redux 里 reducers 的要求,并不是 class 的 setState 的需求)。 修改上述代码如下

const handleAdd = () => {
    setItems(items => {
      const newItems = [
        ...items,
        {
          text: name,
          done: false,
          id: uuid(),
        },
      ] // 保证每次都生成新的items,这样才能保证组件的刷新
      return newItems
    })
  }

更加复杂的对象的 immutable 更新就没那么容易了

很可惜 Javascript 并没有内置对这种 Immutable 数据的支持,更别提对 Immutable 数据更新的支持了,但是借助于一些第三方库如 immer 和 immutablejs,可以简化我们处理 immutable 数据的更新。

import { produce } from 'immer'
const handleAdd = () => {
  setItems(
    produce((items) => {
      items.push({
        text: name,
        done: false,
        id: uuid()
      })
    })
  )
}

他们都是通过 structing shared 的方式保证我们只更新了修改的子 state 的引用,不会去修改未更改子 state 的引用,保证整个组件树的缓存不会失效。

另外:函数属性同样需要

const handleClick = useCallback(handleClick, ['state'])

移动端

优化无限列表性能

如果你的应用存在非常长或者无限滚动的列表,那么采用 窗口化 的技术来优化性能,只需要渲染少部分区域的内容,减少重新渲染组件和创建 dom 节点的时间。

vue-virtual-scroll-list 和 vue-virtual-scroller 都是解决这类问题的开源项目。你也可以参考 Google 工程师的文章 Complexities of an Infinite Scroller 来尝试自己实现一个虚拟的滚动列表来优化性能,主要使用到的技术是 DOM 回收、墓碑元素和滚动锚定。

devtools 性能分析

其他

多进程 web worker,web assembly

算法,递归变迭代,暴力变 DP

设计模式:享元模式(flyweight),单例模式等

防抖节流,GPU 加速,动画优化

参考:

《high performance javascript》 《js 设计模式》

hackernoon.com/lazy-loadin…

zhuanlan.zhihu.com/p/163590288

zhuanlan.zhihu.com/p/115047733

www.zhihu.com/collection/…