前端性能优化
前端性能优化
参考:https://mp.weixin.qq.com/s/3o96MEIw_sPTPfsNO8PqUA
渲染优化
js css加载顺序
渲染树的构成必须要 DOM 树和 CSSOM 树的,所以尽快的构建 CSSOM 树是一个重要的优化手段,如果 css 文件放在尾部,那么整个过程就是一个串行的过程先解析了 dom,再去解析 css。所以 css 我们一般都是放在头部,这样 DOM 树和 CSSOM 树的构建是同步进行的。
再来看 js,因为 js 的运行会阻止 DOM 树的渲染的,所以一旦我们的 js 放在了头部,而且也没有异步加载这些操作的话,js 一旦一直在运行,DOM 树就一直构建不出来,那么页面就会一直出现白屏界面,所以一般我们会把 js 文件放在尾部。当然放到尾部也不是就没有问题了,只是问题相对较小,放到尾部的 js 文件如果过大,运行时间长,代码加载时,就会有大量耗时的操作造成页面不可点击,这就是另一个问题,但这肯定比白屏要好,白屏是什么页面都没有,这种是页面有了只是操作不流畅。
js 脚本放在尾部还有一个原因,有时候 js 代码会有操作 dom 节点的情况,如果放在头部执行,DOM树还没有构建,拿不到 DOM 节点但是你又去使用就会出现报错情况,错误没处理好的话页面会直接崩掉
减少重排和重绘
- js 尽量减少对样式的操作,能用 css 完成的就用 css
- 对 dom 操作尽量少,能用 createDocumentFragment 的地方尽量用
- 如果必须要用 js 操作样式,能合并尽量合并不要分多次操作
- resize 事件 最好加上防抖,能尽量少触发就少触发
- 加载图片的时候,提前写好宽高
使用浏览器缓存
强缓存和协商缓存
请求优化
减少请求数量
- 将小图片打包成base64
- 利用雪碧图融合多个小图片
- 利用缓存
减少请求时间
- 将js,css,html等文件能压缩的尽量压缩,减少文件大小,加快下载速度
- 利用webpack打包根据路由进行懒加载,不要初始就加载全部,那样文件会很大
- 能升级到高版本的http就升级到高版本
- 建立内部CDN能更快速的获取文件
webpack打包优化
基础配置优化
- extensions 这个配置是属于resolve里面的,经常用来对文件后缀进行扩展,写法如下:
resolve: { extensions: ['.ts', '.tsx', '.js'] }
这个配置表示 webpack 会根据 extensions 去寻找文件后缀名,所以如果我们的项目主要用 ts 写的话,那我们就可以 .tsx 和 .ts 写前面,目的是为了让 webpack 能够快速解析
- alias 这个配置也是属于resolve里面的,是用来映射路径,能减少打包时间的主要原因是能够让webpack 快速的解析文件路径,找到对应的文件,配置如下
resolve: { alias: { Components: path.resolve(__dirname, './src/components') } }
- noParse
noParse 表示不需要解析的文件,有的文件可能是来自第三方的文件,被 providePlugin 引入作为 windows上的变量来使用,这样的文件相对比较大,并且已经是被打包过的,所以把这种文件排除在外是很有必要的,配置如下
module: { noParse: [/proj4\.js/] }
- exclude
某些 loader 会有这样一个属性,目的是指定 loader 作用的范围,exclude表示排除某些文件不需要 babel-loader 处理,loader的作用范围小了,打包速度自然就快了,用 babel-loader 举一个简单例子
{ test: /\.js$/, loader: "babel-loader", exclude: path.resolve(__dirname, 'node_modules') }
- devtool
这个配置是一个调试项,不同的配置展示效果不一样,打包大小和打包速度也不一样,比如开发环境下cheap-source-map 肯定比 source-map 快。
{ devtool: 'cheap-source-map' }
.eslintignore
这个虽不是 webpack 配置但是对打包速度优化还是很有用的,在实践中 eslint 检查对打包的速度影响很大,但是很多情况我们不能没有这个 eslint 检查,eslint 检查如果仅仅在vs里面开启的话,可能不怎么保险。
因为有可能 vs中 的 eslint 插件突然关闭了或者某些原因 vs 不能检查了,只能靠 webpack 构建去帮你拦住错误代码的提交,即使这样还不能确保万无一失,因为你可能某一次提交代码很急没有启动服务,直接盲改提交上去了。这个时候只能通过最后一道屏障给你保护,就是在 CI 的时候。比如我们也会是在 jenkins 构建的时候帮你进行 eslint 检查,三道屏障确保了我们最终出的镜像是不会有问题的。
所以 eslint 是很重要的,不能删掉,在不能删掉的情况下怎么让检查的时间更少了,我们就可以通过忽略文件,让不必要的文件禁止 eslint ,只对需要的文件 eslint 可以很大程度提高打包速度
loader,plugins优化
下面是某些 loader,plugins 来提高打包速度的例子
- cache-loader
这个 loader 就是在第一次打包的时候会缓存打包的结果,在第二次打包的时候就会直接读取缓存的内容,从而提高打包效率。但是也需要合理利用,我们要记住一点你加的每一个 loader,plugins 都会带来额外的打包时间。这个额外时间比他带来的减少时间多,那么意味着增加这个 loader 就没意义,所以 cache-loader 最好用在耗时比较大的 loader 上,配置如下
{ rules: [ { test: /\.vue$/, use: [ 'cache-loader', 'vue-loader' ], include: path.resolve(__dirname, './src') } ] }
- webpack-parallel-uglify-plugin,uglifyjs-webpack-plugin,terser-webpack-plugin
在上面的渲染优化中我们已经知道,文件越小渲染的速度是越快的。所以我们在配置 webpack 时候经常会用到压缩,但是压缩也是需要消耗时间的,所以我们我们经常会用到上面三个插件之一来开启并行压缩,减少压缩时间,用 webpack4 推荐使用的 terse-webpack-plugin 做例子来说明
optimization: { minimizer: [ new TerserPlugin({ parallel: true, cache: true }) ], }
- happypack,parallel-webpack,thread-loader
这几个 loader/plugin 和上面一样也是开启并行的,只不过是开启并行构建。由于 happypack 的作者说自己的兴趣已经不再js上了,所以已经没有维护了,并推荐如果使用的是 webpack4 的话,就去使用 thread-loader。基本配置如下
{ test: /\.js$/, use: [ { loader: "thread-loader", options: threadLoaderOptions }, "babel-loader", ], exclude: /node_modules/, }
- DllPlugin,webpack.DllReferencePlugin
上面说的几个并行插件理论上是可以增加构建速度,网上很多文章都是这么说的,但是我在实际的过程中使用,发现有时候不仅没提升反而还降低了打包速度,网速查阅给的理由是可能你的电脑核数本来就低,或者当时你CPU运行已经很高了,再去开启多进程导致构建速度降低。
上面说的几个并行插件可能在某些情况下达不到你想要的效果,这次所说的两个插件是很明显并且每次都能提高打包速度的。原理就是先把第三方依赖先打包一次生成一个 js 文件,然后真正打包项目代码时候,会根据映射文件直接从打包出来的 js 文件获取所需要的对象,而不用再去打包第三方文件。只不过这种情况打包配置稍微麻烦点,需要写一个 webpack.dll.js。大致如下
webpack.dll.js
const path = require('path'); const webpack = require('webpack'); module.exports = { entry: { library: ["vue", "moment"] }, output: { filename: '[name].dll.js', path: path.resolve(__dirname, 'json-dll'), library: '[name]' }, plugins: [ new webpack.DllPlugin({ path: './json-dll/library.json', name: '[name].json' }) ] }
webpack.dev.js
new AddAssetHtmlWebpack({ filepath: path.resolve(__dirname, './json-dll/library.dll.js') }), new webpack.DllReferencePlugin({ manifest: require("./json-dll/library.json") })
代码优化
能不操作dom不要操作dom,哪怕有时候需要改设计
很多情况下我们都能用 css 还原设计稿,但是有些时候单单从 css 没法还原,尤其组件还不是你写的时候,有时候的产品设计单从 css 上没法实现,只能动用 js ,删除,增加节点在配合样式才能完成。
所以代码优化的原则之一是能不写的代码就不写,当然这是要从性能角度出发,通过性能分析给产品说出理由,并且最好还能提供更好的解决方案,这个才是我们需要考虑的。
如果用的是react 一定用写 shouldComponentUpdate 这个生命周期函数,不然打印的时候你会发现,你自己都迷糊为什么执行了这么多遍
将复杂的比对,变成简单比对
这句话是什么意思了?就拿 shouldComponentUpdate 举例子,用这个函数没问题,但是可以做的更好
shouldComponentUpdate(nextPrpops) { return JSON.stringify(nextPrpops.data) !== JSON.stringify(this.props.data) }
如果这是一个分页表格,data是每一页数据,数据改变了重新渲染,在小数据场景下这本身是没有问题。但是如果在大数据的场景下可能会有问题,比如下面这样
shouldComponentUpdate(nextPrpops) { return nextPrpops.data[0].id !== this.props.data[0].id }
第一条的 id 不一样就表示数据变化了行不行,显然在某种情况下是存在的,也有人会说可能会出现id一样,那如果换成下面这种了?
shouldComponentUpdate(nextPrpops) { return nextPrpops.current !== this.props.current }
将 data的 比对转换成了 current 的比对,因为页数变了,数据基本都是变了,对于我们自己日志的展示来说基本不存在两页数据是一模一样的,如果有那可能是后台问题。然后再好好思考这个问题,即使存在了两页数据一摸一样,顶多就是这个表格不重新渲染,但是两页数据一摸一样不重新渲染是不是也没有问题,因为数据是一样的。或者如果你还是不放心,那下面这种会不会好点
this.setState({ data, requestId: guid() }) shouldComponentUpdate(nextPrpops) { return nextPrpops.requestId !== this.props.requestId }
给一个 requestId 跟踪 data,后面就只比对 requestId。上面的写法可能都有问题,但是主要是想说的是我们在写代码时候可以想想是不是可以"将复杂的比对,变成简单比对"
学习数据结构和算法,一定会在你的工作中派上用场
我们经常会听到学习数据结构和算法没有什么大的用处,因为工作基本用不上。这句话我之前觉得没错,现在看来错的很严重。我们所学的每一样技能,都会在将来的人生中派上用场。之前写完代码就丢了不去优化所以我觉得算法没意义,又难又容易忘记。但现在要求自己做完需求,开启 mock,打开 perfermance 进行大数据量的测试,看着那些标红的火焰图和肉眼可见的卡顿,就明白了算法和数据结构的重要性,因为此时你只能从它身上获取优化,平时你很排斥它,到优化的时候你是那么想拥有它。伪代码如下
data.filter(({id}) => { return selectedIds.includes(id); })
就是这样几行代码,逻辑就是筛选出data里面已经被勾选的数据。基本上很多人都可能这么写,因为我看我们团队里面都是这么写的。产品当时已经限制data最多200数据,所以写完完全没压力,性能没影响。但是秉着对性能优化的原则,我开启了mock服务,将数据调到了2万条再去测试,代码弊端就暴露出来了,界面进入卡顿,重新选择的时候也会卡顿。然后就开始了优化,当时具体的思路如下
按照现在的代码来看,这是一个两层循环的暴力搜索时间复杂度为O(n^2)。所以想着能不能降一下复杂度至少是O(nlogn),看了一下代码只能从selectedIds.includes(id)这句入手,于是想着可不可以用二分,但是立马被否定因为二分是需要有序的,我这数组都是字符串怎么二分。
安静了一下之后,回想起看过的算法课程和书籍以及做的算法题,改变暴力搜索的方法基本都是
1:上指针
2:数组升维
3:利用hash表
前两者被我否定了因为我觉得还没那么复杂,于是利用hash表思想解决这个问题,因为 js 里面有一个天然的hash表结构就是对象。我们知道hash表的查询是O(1)的,所以我将代码改写如下
const ids = {}; selectedIds.forEach(id => ids[id] = 1); data.filter(({id}) => { return !!ids[id]; })
将从selectedIds查询变成从ids查询,这样时间复杂度就从O(n^2)变成了O(n)了,这段代码增加了
const ids = {}; selectedIds.forEach(id => ids[id] = 1);
其实增加了一个selectedIds遍历也是一个O(n)的复杂度,总来说复杂度是O(2n),但是从时间复杂度长期期望来看还是一个O(n)的时间复杂度,只不过额外增加了一个对象,所以这也是一个典型的空间换时间的例子,但是也不要担心,ids用完之后垃圾回收机制会把他回收的。
前端工程化