这篇文章算是最近学习前端优化时的一点心得,为了对比强烈降低了CPU性能,文中代码在 github上也有一部分。
本文性能截图来自chrome自带的performance,不了解的可以看看跟他差不多的前世chrome timeline(介绍 传送门)。
基础
CSS选择符优化
众所周知,css选择符解析顺序为从右向左,所以#id div
的解析速度就不如div #id
减少回流重绘
浏览器渲染大致流程是这样的:
- 处理HTML标记并构造DOM树。
- 处理CSS标记并构造CSS规则树。
- 将DOM树与CSS规则树合并成一个渲染树。
- 根据渲染树来布局,以计算每个节点的几何信息。
- 将各个节点绘制到屏幕上。
当 Render Tree 中部分或全部, 因元素的尺寸、布局、隐藏等改变而需要重新构建,浏览器重新渲染的过程称为回流。
会导致回流的操作:
- 页面首次渲染。
- 浏览器窗口大小发生改变。
- 元素尺寸或者位置发生改变。
- 元素内容变化(文字数量或者图片大小发生改变)。
- 元素字体大小的改变。
- 添加或者删除可见的
DOM
元素。 - 激活
CSS
伪类。 - 查询某些属性或调用某些方法。
一些常用且会导致回流的属性和方法。
-
clientWidth
、clientHeight
、clientTop
、clientLeft
-
offsetWidth
、offsetHeight
、offsetTop
、offsetLeft
-
scrollWidth
、scrollHeight
、scrollTop
、scrollLeft
-
scrollIntoView()
、scrollIntoViewIfNeeded()
getComputedStyle()
getBoundingClientRect()
scrollTo()
当页面中元素样式的改变并不影响布局时(像color
、background-color
等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。回流必将引起重绘,重绘不一定会引起回流。
- 缓存layout属性
浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。但是访问前面所说的属性和调用方法,浏览器会为了准确性而清空队列强制进行回流,所以我们可以缓存layout属性,来避免这种现象,比如写滚动加载的时候,就可以缓存offsetTop等属性,避免每次对比都产生回流。如果你没有这样做,可爱的浏览器甚至会提醒Forced reflow is a likely performance bottleneck.
- 将多次回流的元素放在absolute中
使用absolute,把会引起回流的动画脱离文档流,它的变化就不会影响到其他元素。需要注意的是,虽然float
也是脱离文档流,但其他盒子内的文本依然会为这个元素让出位置,环绕在周围。而对于使用absolute
脱离文档流的元素,其他盒子与其他盒子内的文本都会无视它。才是真正的不会影响。(实际测试float
甚至还不如relative
) - 批量修改DOM
比如一个列表,现在需要向里面push100项新内容,一项一项添加的话,则至少会有100次回流,如果使用DocumentFragment
分10次处理,就只会有10次回流。那是不是只处理一次,就会有一次回流,这样性能更好呢,并不是。举个栗子,我想要吃100份炸鸡,如果一份一份吃会很累,如果一次直接吃100份,会直接撑炸,比较好的方式就是分10次吃,每次吃10份。这其中涉及到的long task概念,也是下一个优化方式所涉及的。
任务切片
学名task-slice,算是一个必备的优化方式了,着重说一哈,先来看吃炸鸡的例子,为了突出优化前后差异把要吃的炸鸡变成1000份。
实验1:一份一份吃 吃1000次
可以看到黄条代表的scripting从一段变成了好几段,对应的task也从一长条分了好几份。前文中缓存layout属性的部分讲过,浏览器会维护一个队列,所以实验1和实验2结果差距不大是因为他们都被放进队列中最后统一处理,而task-slice做的就是把一个long task,分成几个小task交给浏览器依次处理,缓解浏览器短时间内的压力。帧数也从2提升到了10+。(因为我测试时阉割了性能,所以优化后帧数依然感人)
上面这个例子,是同步的任务切片,那万一可爱的项目经理说要加10个echarts图咋办嘞。
其实同步和异步差不多的,上一个简单版本的代码
function TaskSlice(num, fn) {
this.init(num, fn)
}
TaskSlice.prototype = {
init: (num, fn) => {
let index = 0
function next() {
if(index < num) {
fn.call(this, index, next)
}
index++
}
next()
}
}
使用的时候就这样
function drawCharts (num) {
new TaskSlice(
num,
drawChart
)
}
function drawChart(id, cb) {
var chart = echarts.init(document.getElementById(id))
chart.on('finished', cb)
chart.on('finished', () => {
chart.off()
})
chart.setOption(options)
}
因为echarts
的生命周期是自己内部定义的事件,所以看起来比较麻烦,如果想要切片的异步任务是promise
就比较简单了
function asyncTask(cb) {
promise().then(() => {
// balabalaba
cb()
})
}
这个类的逻辑大概是这样的:
初始化时传入要切片的次数num
和异步的任务fn
;
然后定义一个函数next
,next
通过闭包维护一个表示当前执行任务次数的变量index
,然后调用next
进入函数内逻辑;
判断执行次数是否小于要切的次数,小于的话,调用fn
,同时给他两个参数分别为当前执行次数和next
;
然后进入fn
函数,这里只需要在异步完成后调用next
,任务就被切成了好多片。
减少作用域查找
作用域链和原型链类似,当我们使用对象的某一个属性时,会遍历原型链,从当前对象的属性开始,如果找不到该属性,则会在原型链上往下一个位置移动,寻找对应的属性,直到找到属性或者达到原型链末端。
在作用域链中,首先会在当前作用域中寻找我们需要的变量或者函数,如果没找到的话,则往上一个作用域寻找,直到找到变量/函数或者到达全局作用域。
//bad
var a=1;
function fn(){
console.log(a);
}
fn()
//good
var a=1;
function fn(value){
console.log(value);
}
fn(a)
节流防抖
throttle&debounce,这个网上文章太多了,而且像lodash
这种工具库也有现成的源码,我也写了一个简版的,可能更通俗一点,就在文章开头说的github里,需要注意的是他们不能减少事件的触发次数。学就完事儿了。
懒加载
先将img标签中的src链接设为同一张图片,将其真正的图片地址存储在img标签的自定义属性。当js监听到该图片元素进入可视窗口时,再把src的值替换为自定义属性,减少首屏加载的请求数量,达到懒加载的效果。
其中的定义滚动事件,和计算是否进入可视窗口,就用到了前面说的防抖和缓存layout属性
let pending = false
function LazyLoad({ els, lazyDistance }) {
this.lazyDistance = lazyDistance
this.imglist = Array.from(els)
this.loadedLen = 0
this.init()
}
LazyLoad.prototype = {
init: function() {
this.initHandler()
this.lazyLoad()
},
load: function(el) {
if(!el.loaded) {
el.src = el.getAttribute('data-src')
this.loadedLen++
el.loaded = true
}
},
lazyLoad: function() {
for(let i = 0; i < this.imglist.length; i++) {
this.getBound(this.imglist[i]) && this.load(this.imglist[i])
}
pending = false
},
getBound: function(el) {
let bound = el.getBoundingClientRect()
let clientHeight = document.documentElement.clientHeight || document.body.clientHeight
return bound.top <= clientHeight + this.lazyDistance
},
initHandler: function() {
const fn = throttle(function() {
if(!pending) {
pending = true
if(this.imglist.length > this.loadedLen) {
this.lazyLoad()
} else {
window.removeEventListener('scroll', this.scrollHander, false)
}
}
}, 1000)
this.scrollHander = fn.bind(this)
window.addEventListener('scroll', this.scrollHander, false)
},
}
Vue
函数式组件
可以把没有状态,没有this上下文,没有生命周期的组件,写为函数式组件,因为函数式组件只是函数,所以渲染开销也低很多。具体写法官网传送门
拆分子组件
因为vue的渲染顺序为先父到子,所以拆分子组件类似上面所说的task slice
。就是把一个大的task分成了父和子两个task。
使用v-show复用dom
下面这段话抄自官网
v-if 是“真正”的条件渲染,因为它会确保在切换过程中条件块内的事件监听器和子组件适当地被销毁和重建。
v-show 就简单得多——不管初始条件是什么,元素总是会被渲染,并且只是简单地基于 CSS 进行切换。
使用keep-alive进行缓存
keep-alive是Vue内置组件,会缓存该组件内的组件的实例,节省再次渲染时初始化组件的花销。
延迟加载DOM
这一项其实还是任务切片,但是这种实现方式真的和Vue特别契合,直接上代码
export default function (count = 10) {
return {
data () {
return {
displayPriority: 0,
}
},
mounted () {
this.runDisplayPriority()
},
methods: {
runDisplayPriority () {
const step = () => {
requestAnimationFrame(() => {
this.displayPriority++
if (this.displayPriority < count) {
step()
}
})
}
step()
},
defer (priority) {
return this.displayPriority >= priority
},
},
}
}
函数返回一个mixin
,通过defer
函数和v-if
来控制切片,像这样:
不响应式数据
- 众所周知,new一个Vue时,Vue会遍历data中的属性通过
Object.defineProperty
(2.x版本)来将他们设置为响应式数据,当其中的属性变化时,通过触发属性的set
去更新View。那么如果只是为了定义一些常量,我们就不需要vue去设置他们为响应式,写在created
里面就可以了。 -
一个table组件的props肯定会有一个数组,常见的写法像这样
我一开始也觉得这种写法无比正常,list需要是响应式的,因为需要table随着list的改变而改变,更何况
element-ui
官网的示例就是将list的声明放在data中。然鹅,真正起作用的是作为props传进table组件的list,而不是再父组件中的list。所以这个list的声明也是没有必要放在data里的。 -
还是以上面的table组件为例,因为vue会递归遍历data和props的所有属性,所以当list传进时,假设list的结构是这样的
[{id: 1, name: '前端'}]
,那么id和name两个属性也会被设置为响应式,如果需求这两个属性只需要展示,那么可以这样做function optimizeItem (item) { const data = {} Object.defineProperty(data, 'data', { configurable: false, value: item, }) return data }
通过设置属性的configurable为false来阻止vue再去修改他。
webpack
缩小文件搜索范围
- 优化loader配置
使用loader时可以通过test
、include
、exclude
来命中匹配的文件,让尽可能少的文件被处理。 -
resolve.alias
resolve.alias 通过别名来把原导入路径映射成一个新的导入路径,在项目种经常会依赖一些庞大的第三方模块,以react为例,默认情况下 Webpack 会根据库的package.json中定义的入口文件 ./node_modules/react/react.js 开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。 通过配置 resolve.alias 可以让 Webpack 在处理 React 库时,直接使用单独完整的 react.min.js 文件,从而跳过耗时的递归解析操作。(vue系的库的入口文件就直接是一个单独的完整的文件,牛批)
一般对于整体性强的库可以使用这种方法,但是像loadsh这种,可能只使用了其中几个函数,如果也这样设置,就会导致输出文件中有很多废代码。resolve: { alias: { 'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js') } },
-
resolve.extensions
在导入语句没带文件后缀时,Webpack 会根据resolve.extensions
的配置带上后缀后去尝试询问文件是否存在。默认值是['.wasm', '.mjs', '.js', '.json']
(v4.41.2)。也就是说当遇到 require('./data') 这样的导入语句时,Webpack 会先去寻找 ./data.wasm 文件,如果该文件不存在就去寻找 ./data.mjs 文件,以此类推,最后如果找不到就报错。
如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以 resolve.extensions 的配置也会影响到构建的性能。 在配置 resolve.extensions 时你需要遵守以下几点,以做到尽可能的优化构建性能:
1、后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表。
2、频率出现高的文件后缀要优先放在前面。
3、在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把 require('./data') 写成 require('./data.json')。resolve: { extensions: ['.js', '.vue'], },
-
module.noParse
这个配置项可以让webpack对没有采用模块化的文件不进行处理,被忽略的文件不应该具有import、require等导入机制的调用。像上面resolve.alias
中的单独的完整的react.min.js
就没有采用模块化。忽略之后可以提高构建性能。module: { noParse: [/vue\.runtime\.common\.js$/], },
压缩代码
浏览器从服务器访问网页时获取的 JavaScript、CSS 资源都是文本形式的,文件越大网页加载时间越长。 为了提升网页加速速度和减少网络传输流量,可以对这些资源进行压缩。js可以使用webpack内置的uglifyjs-webpack-plugin
插件,css可以使用optimize-css-assets-webpack-plugin
optimization: {
minimizer: [
new UglifyJsPlugin(),
new OptimizeCSSAssetsPlugin()
]
}
DllPlugin
dll是动态链接库,在一个动态链接库中可以包含给其他模块调用的函数和数据。包含基础的第三方模块(如vue全家桶)的动态链接库只需要编译一次,之后的构建中这些模块就不需要重新编译,而是直接使用动态链接库中的代码。所以会大大提升构建速度。
具体操作是使用DllPlugin
和DllReferencePlugin
这两个内置的插件,前者用于打包出动态链接库文件,后者用于主webpack配置中去引用。
// 打包dll
entry: {
vendor: ['vue', 'vue-router', 'vuex'],
},
output: {
filename: '[name].dll.js',
path: path.resolve(__dirname, 'dist'),
library: '_dll_[name]',
},
plugins: [
new DllPlugin({
name: '_dll_[name]',
path: path.join(__dirname, 'dist', '[name].manifest.json'),
}),
],
// output和plugins中的[name]都是entry中的key,
// 也就是'vender'
// 引用
plugins: [
new DllReferencePlugin({
manifest: require('../dist/vendor.manifest.json'),
}),
]
happypack
由于运行在Node.js之上的Webpack是单线程的,所以Webpack需要处理的任务会一件件挨着做,不能多个事情一起做。而HappyPack可以把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程。不多bb上代码
const HappyPack = require('happypack')
module: {
rules: [
{
test: /\.js$/,
use: ['happypack/loader?id=babel']
}
],
},
plugins: [
new HappyPack({
// 用唯一的标识符 id 来代表当前
// 的 HappyPack 是用来处理一类特定的文件
id: 'bable',
loaders: ['babel-loader'],
})
]
但是HappyPack(v5.0.1)并不支持vue-loader(v15.3.0)(支持列表),而在vue的项目中,使用模板语法的话大部分的业务js都是写在.vue
文件中的,就可以通过配置vue-loader的options部分,将js部分交由happypack处理
好像之前的vue-loader是支持的,改成需要在pulgins里面单独声明之后就不行了,而vue-loader升级是加快了打包速度的,强行为了使用happypack而降级有点舍本逐末的味道。
//rules: [
// {
// test: /\.vue$/,
// use: [
// {
// loader: 'vue-loader',
// options: {
// loaders: {
// js: 'happypack/loader?id=babel'
// },
// }
// }
// ]
// }
//]
不支持也没有关系,vue Loader文档有说,在pulgins中引用可以将你定义过的其它规则复制并应用到.vue
文件里相应语言的块。例如,如果你有一条匹配/\.js$/
的规则,那么它会应用到.vue
文件里的块。
其他
WebAssembly
了解这个东西是看webpack文档的时候,发现resolve.extensions
的默认配置是['.wasm', '.mjs', '.js', '.json']
,这个wasm甚至是排在第一位的,就去了解了一下,真是不看不知道一看吓一跳,这玩意儿也忒厉害咧,我的理解浏览器识别js代码的大概流程是下载->转换->编译,但是wasm可以跳过转换和编译两步,因为他本身就可以被浏览器识别,从而而且最近WebAssembly
也正式加入到W3C标准了,别问,问就是知识点。放一个[mdn对于WebAssembly的介绍]当作拓展阅读(https://developer.mozilla.org...