代码优化
JS开销和如何缩短解析时间【为什么我的JS运行慢】js开销在哪里解决方案减少主线程工作量Progressive Bootstrapping(渐进式启动)配合V8 有效优化代码【路走对了才能快】V8编译原理抽象语法树V8优化机制函数优化函数的解析方式对象优化【JS对象避坑地图】对象优化可以做哪些HTML优化借助工具CSS对性能的影响样式计算开销CSS优化JS开销和如何缩短解析时间【为什么我的JS运行慢】
js开销在哪里
加载解析&编译执行资源大小相同的情况下,js的开销更高
170kb的js和jpg,通过网络加载的时间一致(加载过程只是大小影响),但是js后面要经历编译解析(2s)、执行(1.5s),jpg要经历解码(64.9ms)、图片绘制到页面(0.028s)
summary里可以看到解析的是哪个脚本
Bottom-Up自下而上,是一个拆分,里面具体做了哪些事,耗时多久,解析好事1757ms,垃圾回收时间61.5ms,编译1.6ms
对于一个网站而言,总共的网络加载过程中,压缩后1.4M的js在整个网络加载耗时中占1/3
解决方案
Code splitting代码拆分,按需加载当前访问路径需要哪些资源加载哪些资源,不需要的进行延迟,或者访问需要它的页面时再加载Tree shaking代码减重
不用的代码摇掉
减少主线程工作量
避免长任务避免超过1kb的行间脚本浏览器引擎没办法对行间脚本进行有效优化,行间脚本越大,解析消耗的时间就越长使用rAF和rAC进行时间调度
Progressive Bootstrapping(渐进式启动)
可见不可交互 vs 最小可交互资源集
配合V8 有效优化代码【路走对了才能快】
V8编译原理
V8是chrome浏览器的js引擎,它是目前做得最好、效率最高的js引擎,后台nodejs也是采用v8引擎
浏览器或者v8引擎拿到js脚本后,首先进行parse it(解析),将它翻译成抽象语法树(AST),所有的编程语言都有这样的过程,要把文本识别成字符,然后把重要信息提取出来,变成一些节点,存储在一定的数据结构里,再利用数据结构理解你写的内容是什么寓意,理解什么寓意是Interpreter(解释器)要做的事,在把代码编程机器码去运行之前,编译器会进行优化工作,这个编译器是Optimising Compiler(有优化功能的编译器),有时它做的自动优化工作并不一定合适,所以再运行时,发现所做优化不合适时,会发生逆优化、反优化的过程,把刚刚做的优化去掉,这样的情况反而会降低我们的效率,所以在代码层面做的优化是尽量满足优化的条件,它怎么做优化,我们按照它期望的代码去写,回避造成它反优化过程的代码
下面写个逆优化代码,运行在node环境下
const {performance, PerformanceObserver} = require('perf_hooks');const add = (a, b) => a+b;const num1 = 1;const num2 = 2;performance.mark('start');for(let i = 0; i < 10000000; i++) {add(num1, num2);}add(num1, 's');for(let i = 0; i < 10000000; i++) {add(num1, num2);}performance.mark('end');const observer = new PerformanceObserver((list) => {console.log(list.getEntries()[0]);})observer.observe({entryTypes: ['measure']});performance.measure('测量1', 'start', 'end');
代码运行时间大概是54ms,注释add(num1, ‘s’);再运行,代码运行时间减少到19ms,看代码就是add函数,虽然调了很多次,但是参数很稳定,每次都是两个数相加,这两个数都不变,所以在编译过程中会对这个函数进行优化,如果打开add(num1, ‘s’),在某次执行函数时,发现参数类型发生变化,运行时不能用已经做过优化的逻辑了,要把刚做的优化撤销掉,这样会带来一定的延迟
如果想进一步了解v8到底对什么做了优化,对什么做了反优化,可以利用node的两个参数(trace-opt,trace-deopt)
抽象语法树
源码=>抽象语法树=>字节码Bytecode=>机器码编译过程会进行优化运行时可能发生反优化V8优化机制
脚本流脚本正常情况下要先下载再进行解析再执行的过程,chrome在这边做了优化,在下载过程中也同时进行解析的话可以加快这个过程,下载一个脚本,当它超过30kb时,它就认为已经足够大,可以对这30kb的内容先进行解析,会单独开一个线程去给这段代码进行解析,等整个都加载完成时,再进行解析时,效率就大大提高了,因为把前面的部分已经解析过了,把所有解析的内容合并下,然后就可以进行执行,这是流式处理的一个特点字节码缓存
如果有些东西使用频率比较高,可以把它进行缓存,再次进行访问时就可以加快访问,源码被翻译成字节码之后,发现有一些不仅在当前页面有使用,在其他页面也会使用的片段,把这些片段对应的字节码缓存起来,在其他页面再次访问相同逻辑时,直接从缓存去取它,不需要再进行翻译的过程,这样效率就大大提高懒解析
主要对于函数而言,虽然声明了这个函数,不一定马上会用它,默认情况下会进行懒解析,先不去解析函数内部的逻辑,当我真正要用时我再去解析函数声明的函数体,不需要解析的话也不需要为它去创建语法树,进一步而言,在我们堆的内存空间里也不用为这个函数进行内存的分配,这样对性能是极大的提升
函数优化
函数的解析方式
lazy parsing懒解析 vs eager parsing饥饿解析不能否认懒解析作为默认的解析方式,可以极大提高js的整体效率,但是在现实中,有时还是希望函数立即执行,这样会有什么问题?如果我们的函数是立即执行的,在刚开始声明的时候,默认对它进行懒解析,但是我们尤发现它要立即执行,于是又进行快速的饥饿解析,这样就对同一个函数先进行懒解析再进行饥饿解析,导致效率降低了一半,所以需要一种方式告诉我们的解析器,我这个函数需要立即执行,你现在就对它进行饥饿解析,接下来我们看下在代码里怎么告诉解析器我的函数是需要进行eager parsing的,也进行性能的前后对比
// test.jsexport default () => {const add = (a, b) => a*b; // lazy parsing// const add = ((a, b) => a*b); // eager parsingconst num1 = 1;const num2 = 2;add(num1, num2);}// 默认情况下当它读到add函数声明(const add = (a, b) => a*b;)时,是使用lazy parsing,只记下来这个声明,并不对它进行解析,到add(num1, num2)遇到函数调用时,真正的对函数进行解析,再进行调用,我们自己在写逻辑时,自己是清楚的,很快就要调用函数,所以在我们声明时,我们需要它解析声明的同时,能把函数的函数体也进行解析,调用的时候效率反而会更高,const add = ((a, b) => a*b); 就可以进行eager parsing
// App.jsximport test from './test';constructor(props) {super(props);// this.calculatePi(1500); // 测试密集计算对性能的影响test(); // 测试函数lazy parsing, eager parsing}
// webpack.config.jsentry: {app: './src/index.jsx',test: './src/test.js' // 测试函数lazy parsing, eager parsing},output: {path: `${__dirname}/build`,filename: '[name].bundle.js'},
npm start运行,分析发现test.bundle.js的解析时间大概是0.4ms
利用Optimize.js优化初次加载时间
js会进行压缩,当用工具进行压缩时,实际上又会帮我们把eager parsing的括号去掉,会导致我们本来想做的事没办法通知到解析器,为了处理和解决这个问题,有人做了Optimize.js这个工具,帮助我们在这种情况下把括号添加回来
对象优化【JS对象避坑地图】
对象优化可以做哪些
做这些优化的根据是迎合V8引擎进行解析,把你的代码进行优化,它也是用代码写的,它所做的优化其实也是代码实现的一些规则,如果我们写的代码可以迎合这些规则,就可以帮你去优化,代码效率可以得到提升
以相同顺序初始化对象成员,避免隐藏类的调整
js是动态、弱类型语言,写的时候不会声明和强调它变量的类型,但是对于编辑器而言,实际上还是需要知道确定的类型,在解析时,它根据自己的推断,它会给这些变量赋一个具体的类型,它有多达21种的类型,我们管这些类型叫隐藏类型(hidden class),之后它所做的优化都是基于hidden class进行的
class RectArea {// HC0 constructor(l, w) {this.l = l; // HC1this.w = w; // HC2}}// 当声明了矩形面积类之后,会创建第一个hidden class(HC0),const rect1 = new RectArea(3,4); // 创建了隐藏类HC0, HC1, HC2// 对于编辑器而言,它会做相关的优化,你在接下来再创建的时候,还能按照这个顺序做,那么就可以复用这三个隐藏类,所做的优化可以被重用const rect2 = new RectArea(5,6); // 相同的对象结构,可复用之前的所有隐藏类const car1 = {color: 'red'}; // HC0,car1声明对象的时候附带会创建一个隐藏类型car1.seats = 4; // HC1,追加个属性再创建个隐藏类型const car2 = {seats: 2}; // 没有可复用的隐藏类,创建HC2,car2声明时,HC0的属性是关于color的属性,car2声明的是关于seats的属性,所以没办法复用,只能再创建个HC2;HC1不是只包含seats的属性,是包含了color和seats两个属性,也会强调顺序,隐藏类型底层会以描述的数组进行存储,数组里会去强调所有属性声明的顺序,或者说索引,索引的位置car2.color = 'blue'; // 没有可复用的隐藏类,创建HC3
实例化后避免添加新属性
const car1 = {color: 'red'}; // In-object 属性,对象创建就带有的属性car1.seats = 4; // Normal/Fast 属性,存储在property store里,需要通过描述数组间接查找,没有对象本身的属性查找得快
尽量使用Array代替array-like对象
array-like对象:js里都有一个arguments这样的对象,它包含了函数参数变量的信息,本身是一个对象,但是可以通过索引去访问里面的属性,它还有length的属性,像是一个数组,但它又不是数组,不具备数组带的一些方法,比如说foreach
如果本身真的是数组,v8引擎会对这个数组进行极大性能的优化,只是array-like的话,它做不了这些事情,在调用array方法时,通过间接的手段可以达到遍历array-like对象,但是效率没有在真实数组上高
Array.prototype.forEach.call(arrObj, (value, index) => {// 不如在真实数组上效率高console.log(`${index }: ${value }`);});// 将类数组先转成数组,再进行遍历,转换也是有代价的,这个开销与后面性能优化对比怎么样?v8做了实践,得出结论:将类数组先转成数组,再进行遍历比不转换直接使用效率要高,所以我们也最好遵循它的要求const arr = Array.prototype.slice.call(arrObj, 0); // 转换的代价比影响优化小arr.forEach((value, index) => {console.log(`${index }: ${value }`);});
避免读取超过数组的长度
这是讲越界的问题,js里不容易发现这越界问题,越界了也不一定报错
越界比较的话会造成沿原型链额外的查找,这个能相差到6倍
function foo(array) {for (let i = 0; i <= array.length; i++) {// 越界比较,正常是<,这边是<=,超过边界的值也会比较进来,if(array[i] > 1000) {// 1.造成array[3]的值undefined与数进行比较 2.数组本身也是一个对象,在数组对象里找不到要的属性之后,会沿原型链向上查找,会造成额外的开销console.log(array[i]); // 这个数据是无效的,会造成业务上无效、出错} }}// [10,100,1000]
避免元素类型转换
对于编辑器而言,实际上是有类型的
const array = [3, 2, 1]; // PACKED_SMI_ELEMENTS,满的整型元素array.push(4.4); // PACKED_DOUBLE_ELEMENTS,之前对数组具体到PACKED_SMI_ELEMENTS类型所做的优化全都无效,需要对数组类型进行一次更改,变成PACKED_DOUBLE_ELEMENTS类型,会造成额外的开销,编辑器效率就不高了
类型越具体,编辑器能做的优化就越多,如果变得越通用,能做的优化余地就越少
可以去v8官方看看技术博客,会经常更新它们的优化方案,我们如果可以不断配合他们的优化方案,可以让我们代码的效率不断提高
HTML优化
html优化空间比较小,html大小在整个页面所有资源里占比比较小,但是也不能忽视,优化工作要做到极致,即使1kb也不能放弃,
在html里,有很多没有用的空间,还有一些可以省略的元素,就类似上图中的企鹅群,大家可以再挤一挤,挤在一起就可以达到优化的目的
减少iframes使用
额外添加了文档,需要加载的过程,也会阻碍父文档的加载过程,如果它加载不完成,父文档本身的onload事件就不会触发,一直等着它,在iframe里创建的元素,比在父文档创建同样的元素,开销要高出很多;非要用iframe的话,可以做个延时加载,不要一上来就加载iframe,声明一个iframe,在父文档加载完成之后,再拿到iframe,再对src赋值,让它做加载,达到延迟的目的,不会影响刚开始页面的加载过程
压缩空白符
编程的时候,为了方便阅读,会留空白或者空行,这些空白符也是占空间的,最后打包时要把空白符去掉
避免节点深层级嵌套
嵌套越深消耗越高,节点越多最后生成dom树占有内存会比较高,有个遍历,嵌套越深遍历就越慢
避免使用table布局
table布局本身有很多问题,使用起来没有那么灵活,造成的开销非常大,同样实现一种布局的方式,用table布局开发和维护起来,相对而言都更麻烦
删除注释
把无效内容去掉,减少大小
CSS&Javascript尽量外链
CSS和Javascript直接写在行间,会造成html文档过大,对于引擎来说,后续也不好做优化,css和js有时确实要做在行间,这个和偷懒写在行间是两码事
删除元素默认属性
本身默认那个值,没有必要写出来,写出来就添加了额外的字符,要通过网络传送给客户端,这就是一些浪费
head里有很多meta,每个meta要清楚对应的作用,没有用的不要写上去,都是浪费
css通过外部css进行引入
body部分多使用html5的语义标签,方便浏览器理解你写的内容是什么,可以进行相关的优化
有一些元素,前面有open tag,后面有closing tag,并不是所有元素需要closing tag,比如img、li
考虑可访问性,video,浏览器支持或者不支持,还有支持的视频格式都要进行考虑
js要放在body的尾部进行加载,为了防止影响dom的加载,js是阻塞的,如果开始就进行加载,它的加载解析就会影响后面dom的加载
借助工具
html-minifier
CSS对性能的影响
样式计算开销
利用DevTools测量样式计算开销复杂度计算,降低计算的复杂度,对元素进行定义样式,尽量定义单一的样式类去描述它的样式,尽量不要使用过于复杂的伪类,多层级联,去锁定这个元素进行样式描述
css解析的原则是自右向左去读,先会找出最具体的元素,把所有的a全都找出来,再根据#box进行过滤,再进行过滤,再进行过滤,直到把所有受到影响的元素全都过滤出来,然后运用这个样式,随着浏览器解析不断进步,现在这种复杂度的计算已经不是最主要的问题
CSS优化
降低CSS对渲染的阻塞由于CSS对渲染的阻塞是无法进行避免的,所以我们从两个角度进行优化:1尽量早的完成css的下载,尽早的进行解析;2降低css的大小,首次加载时,只加载当前路径或者首屏有用的css,用不到的进行推迟加载,把影响降到最低利用GPU进行完成动画使用contain属性
从上图可以看出,没有使用contain布局消耗的时间大概是56.89ms,使用之后可以降低到0.04ms,这是一个非常大的优化
contain有多个值,layout是其中一个,是现在目前主流浏览器支持比较好的值,作用也比较大
上图是新闻的一个展示页,如果想在第一条内容里插入其他一些内容,对于我们关键渲染路径而言,浏览器并不能知道你插入的东西会不会影响到其他元素的布局,这个时候它就需要对这个页面上的元素进行重新的检查,重新的计算,开销很大,这里有将近10000条的新闻,将近10000个元素要受到影响,如何降低影响?因为我们只是想在第一条里去插入一个东西,后面这些元素本身是不会受到影响的,形状和大小都不会变,这个时候我们就用到contain,contain是开发者和浏览器进行沟通的一个属性,通过contain:layout告诉浏览器,相当于你可以把它看成一个盒子,盒子里所有的子元素和盒子外面的元素之间没有任何布局上的关系,也就是说里面无论我怎么变化不会影响外面,外面怎么变化也不会影响盒子里面,这样浏览器就非常清楚了,盒子里面的元素如果有任何的变化,我会单独的处理,不需要管理页面上其他的部分,这样我们就可以大大减少重新去进行回流或者布局时的计算,这就是contain:layout的作用使用font-display属性,可以帮助我们让我们的文字更早的显示在页面上,同时可以适当减轻文字闪动的问题