位(bit) & 字节(Byte)

详细

bit

1位二进制数,也就是1bit,有2种可能,可以表示数0,1 也就是开关状态 是计算机的存储基础

2位二进制数,2bit,有4种可能(2x2),可以表示数0,1,2,3

3位二进制数,3bit,有8种可能(2x2x2),可以表示数0,1,2,3,4,5,6,7

Byte

大B,表示字节

1Byte = 8 bit, 2^8是256,1个字节能表示的数就是0-255,共256种可能性。

Unicode编码

摘抄

概念

​ Unicode(统一码、万国码、单一码)是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。

UTF-8

概念

UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,又称万国码。UTF-8 用1到6个字节编码Unicode字符。用在网页上可以统一页面显示中文简体繁体及其它语言(如英文,日文,韩文)。

UTF-8是一种非常通用的可变长字符编码方式

像UTF-8里面,ASCII所表示的字符集就是用1 Byte来表示,而大部分汉字则是用3 Byte来表示。

UTF-16

概念

UTF-16 Unicode字符编码五层次模型的第三层:字符编码表(Character Encoding Form,也称为 “storage format”)的一种实现方式。即把Unicode字符集的抽象码位映射为16位长的二进制整数(即码元, 长度为2 Byte)的序列,用于数据存储或传递。Unicode字符的码位,需要1个或者2个16位长的码元来表示,因此这是一个变长表示。

引用维基百科中对于UTF-16编码的解释我们可以知道,UTF-16最少也会用2 Byte来表示一个字符,因此没有办法兼容ASCII编码(ASCII编码使用1 Byte来进行存储)。

JS中的string

在JavaScript中,所有的string类型(或者被称为DOMString)都是使用UTF-16编码的。

因此,当我们需要转换成二进制与后端进行通信时,需要注意相关的编码方式。

JavaScript的开销

js的开销主要在

  • 加载

  • 解析&编译

  • 执行

下图展示了浏览器处理同样大小的普通资源js 所需要的时间

image-20210122143240501

Loding is a journey

image-20210122164337959

减少主线程工作量(优化方案)

  • 避免长任务
  • 避免超过1kb的行间脚本(行内脚本,因为解析引擎无法进行优化)
  • 使用rAF和rIC进行时间调度

V8 编译原理

image-20210122164634444

一般引擎会被代码进行优化后转换为机械码,但是有些代码优化后可能不适合运行,会被回溯到源代码在进行解析,这个过程叫做逆优化,书写过程中尽量避免此类问题。

V8优化机制

脚本流

字节码缓存

懒解析

函数的解析方式
  • 懒解析 lazy parsing
  • 饥饿解析 eager parsing

函数的解析方式,一般来说使用懒解析,函数使用时再进行内容的解析,但是对于立即执行的函数这种优化方式就是逆优化。我们可采取下面的方式告诉解析器直接进行饥饿解析

1
2
3
4
const fnc = (() => {

})
把函数体用括号包裹起来,webpack的打包已经不会把()压缩没了

对象优化

  • 以相同顺序初始化对象成员,避免隐藏类的调整

    动态语言的弊端,解析器会根据推断赋予变量类型(21种),这种类型叫做隐藏类型(hidden class),为了保证hidden class的复用,需要按顺序初始化

  • 实例化后避免添加新属性

    1
    2
    3
    4
    5
    // In-object 属性
    const car = {color: 'red'};

    // normal/fast 属性,存储在property store里,需要通过描述数组间接查找
    car.seats = 4;
  • 尽量使用Array代替array-like对象

    转换的代价比类数组借用call调用数组方法要小(google推荐)

  • 避免数组越界

    • 多数情况下会发生 undefined 类型转换
    • 找不到的数据,会依照原型链向上进行查找(性能相差6倍)
  • 避免元素类型转换

1
2
3
const array = [3, 2, 1]; // PACKED_SMI_ELEMENTS 满的-整型-元素
array.push(4.4); // PACKED_DOUBLE_ELEMENTS
// 对编译器而言,需要更换类型,造成额外开销

导言

每个渲染进程都有一个主线程,并且主线程非常繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。要让这么多不同类型的任务在主线程中有条不紊地执行,这就需要一个系统来统筹调度这些任务,这个统筹调度系统就是我们今天要讲的消息队列事件循环系统

出现原因

  • 事件循环

要想在线程运行过程中,能接收并执行新的任务,就需要采用事件循环机制

  • 消息队列

image-20210705153141673

从上图可以看出,渲染主线程会频繁接收到来自于 IO 线程的一些任务,接收到这些任务之后,渲染进程就需要着手处理。那么如何设计好一个线程模型,能让其能够接收管理其他线程发送的消息呢?

一个通用模式是使用消息队列

消息队列是一种数据结构,可以存放要执行的任务。它符合队列“先进先出”的特点,也就是说要添加任务的话,添加到队列的尾部;要取出任务的话,从队列头部去取

事件循环及消息队列图示

image-20210705154145466

消息队列中的任务类型

输入事件(鼠标滚动、点击、移动)、微任务、文件读写、WebSocket、JavaScript 定时器等等。

​ 除此之外,消息队列中还包含了很多与页面相关的事件,如 JavaScript 执行、解析DOM、样式计算、布局计算、CSS 动画等。

从另一种维度区分,就是宏任务和微任务

微任务是为了解决什么问题?

页面线程所有执行的任务都来自于消息队列。消息队列是“先进先出”的属性,也就是说放入队列中的任务,需要等待前面的任务被执行完,才会被执行。鉴于这个属性,就有如下两个问题需要解决。

微任务就是为了处理高优先级的任务而被创建的。每个宏任务中都包含了一个微任务队列,在执行宏任务的过程中,如果 有微任务产生,那么就会将该变化添加到对应的微任务列表中。当宏任务完成后,程序会直接进入当前宏任务对应的微任务列表中(此时当前的宏任务并没结束)。程序会将微任务队列中所有的微任务执行完毕后才会进入下一个宏任务,如果在微任务执行的过程中产生了新的微任务,仍会被放置进当前的微任务列表等待执行。

微任务、宏任务常见类型

  • macro-task:
    • script(script标签里面的整体代码)
    • setTimeout
    • setInterval
    • setImmediate(node环境和部分ie才有)
    • MessageChannel(vue nextTick 以前应该是备选方案
    • I/O
    • UI rendering
    • requestAnimationFrame
  • micro-task:
    • process.nextTick(node环境才有)
    • Promise
    • Object.observe(已废弃,被下边那个取代了)
    • MutationObserver (接口提供了监视对DOM树所做更改的能力)

setTimeout是如何实现的

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务

当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间,其模拟代码如下所示:

1
2
3
4
5
6
7
8
9
10
struct DelayTask{
int64 id;
CallBackFunction cbf;
int start_time;
int delay_time;
};
DelayTask timerTask;
timerTask.cbf = callbackFnc;
timerTask.start_time = getCurrentTime(); // 获取当前时间
timerTask.delay_time = 200;// 设置延迟执行时间

创建好回调任务之后,再将该任务添加到延迟执行队列中。这里我们要重点关注它的执行时机,下面是一段模拟代码,我们可以看出每完成一个宏任务,程序就回去延迟队列中取出已经到期的定时器任务,执行完成后进行下一轮循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void ProcessTimerTask(){
// 从 delayed_incoming_queue 中取出已经到期的定时器任务
// 依次执行这些任务
}
TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainTherad(){
for (; ;) {
// 执行消息队列中的任务
Task task = task_queue.takeTask();
ProcessTask(task);

// 执行延迟队列中的任务
ProcessDelayTask()

if (!keep_running) // 如果设置了退出标志,那么直接退出线程循环
break;
}
}

pomise

在promise专题内详细介绍,在这里只需要记住promise.resolve promise.reject会产生微任务就可以了

async await

async/await 使用了 GeneratorPromise 两种技术

生成器和协程

生成器函数是一个带星号函数,而且是可以暂停执行和恢复执行的

要搞懂函数为何能暂停和恢复,那你首先要了解协程的概念。协程一种比线程更加轻量级的存在。你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程

正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制

看下面代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function* genDemo() {
console.log(" 开始执行第一段 ")
yield 'generator 1'
console.log(" 开始执行第二段 ")
yield 'generator 2'
console.log(" 开始执行第三段 ")
yield 'generator 3'
console.log(" 执行结束 ")
return 'generator 4'
}

console.log('main 0')
let gen = genDemo()
console.log(gen.next().value)
console.log('main 1')
console.log(gen.next().value)
console.log('main 2')
console.log(gen.next().value)
console.log('main 3')
console.log(gen.next().value)
console.log('main 4')

image-20220722141347660

image-20220228095741824

从图中可以看出来协程的*四点规则******:

  • 通过调用生成器函数 genDemo 来创建一个协程 gen,创建之后,gen 协程并没有立即执行
  • 要让 gen 协程执行,需要通过调用 gen.next。
  • 当协程正在执行的时候,可以通过 yield 关键字来暂停 gen 协程的执行,并返回主要信息给父协程。
  • 如果协程在执行期间,遇到了 return 关键字,那么 JavaScript 引擎会结束当前协程,并将 return 后面的内容返回给父协程

不过,对于上面这段代码,你可能又有这样疑问:父协程有自己的调用栈,gen 协程时也有自己的调用栈,当 gen 协程通过 yield 把控制权交给父协程时,V8 是如何切换到父协程的调用栈?当父协程通过 gen.next 恢复 gen 协程时,又是如何切换 gen 协程的调用栈?

要搞清楚上面的问题,你需要关注以下两点内容。

  • 第一点:gen 协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过 yield 和 gen.next 来配合完成的。

  • 第二点:当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会==保存 gen 协程当前的调用栈信息(包括活动对象和词法作用域)==,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息

为了直观理解父协程和 gen 协程是如何切换调用栈的,你可以参考下图:

image-20210825120626382

tips 加深理解

看一个面试题,可以考察对于切换调用栈的理解

1
2
3
4
5
6
7
8
9
10
var a = 0
var b = async () => {
a = a + await 10
console.log('2', a) // -> '2' 10
a = (await 10) + a
console.log('3', a) // -> '3' 20
}
b()
a++
console.log('1', a) // -> '1' 1

输出

1
2
3
1 1
2 10
3 20

对于以上代码你可能会有疑惑,这里说明下原理

  • 首先函数 b 先执行,在执行到 await 10 之前变量 a 还是 0,因为在 async 内部实现了 generatorsgenerators 会保留堆栈中东西,所以这时候 a = 0 被保存了下来
  • 因为 await 是异步操作,遇到await就会立即返回一个pending状态的Promise对象,暂时返回执行代码的控制权,使得函数外的代码得以继续执行,所以会先执行 console.log('1', a)
  • 这时候同步代码执行完毕,开始执行异步代码,将保存下来的值拿出来使用,这时候 a = 10
  • 然后后面就是常规执行代码了
加深印象的两个问题
  • generator 函数是如何暂停执行程序的?

    答案是通过协程来控制程序执行。

    generator 函数是一个生成器,执行它会返回一个迭代器,这个迭代器同时也是一个协程。一个线程中可以有多个协程,但是同时只能有一个协程在执行。线程的执行是在内核态,是由操作系统来控制;协程的执行是在用户态,是完全由程序来进行控制,通过调用生成器的next()方法可以让该协程执行,通过yield关键字可以让该协程暂停,交出主线程控制权,通过return 关键字可以让该协程结束。协程切换是在用户态执行,而线程切换时需要从用户态切换到内核态,在内核态进行调度,协程相对于线程来说更加轻量、高效。

  • async function实现原理

    async function 是通过 promise + generator 来实现的。

    generator 是通过协程来控制程序调度的。在协程中执行异步任务时,先用promise封装该异步任务,如果异步任务完成,会将其结果放入微任务队列中,然后通过yield 让出主线程执行权,继续执行主线程js,主线程js执行完毕后,会去扫描微任务队列,如果有任务则取出任务进行执行,这时通过调用迭代器的next(result)方法,并传入任务执行结果result,将主线程执行权转交给该协程继续执行,并且将result赋值给yield 表达式左边的变量,从而以同步的方式实现了异步编程。所以说到底async function 还是通过协程+微任务+浏览器事件循环机制来实现的。

async、await的等价表示

写这个主要是帮助理解

1
2
3
4
5
6
7
8
9
async function foo() {
return 1
}

等价于:

function foo() {
return Promise.resolve(1)
}
1
2
3
4
5
6
7
8
9
10
11
12
async function foo() {
await 1
xxxxxxx
}

等价于:

function foo() {
return Promise.resolve(1).then(() => {
xxxxxxx
})
}
async

async 是一个通过异步执行并隐式返回 Promise 作为结果的函数

例如

1
2
3
4
5
async function foo() {
return 2
}
console.log(foo())
// Promise {<resolved>: 2}
await

用代码来分析一下吧

1
2
3
4
5
6
7
8
9
async function foo() {
console.log(1)
let a = await 100
console.log(a)
console.log(2)
}
console.log(0)
foo()
console.log(3)

在详细介绍之前,我们先站在协程的视角来看看这段代码的整体执行流程图:

image-20220722141448815

结合上图,我们来一起分析下 async/await 的执行流程

描述较长,理解记忆

首先,执行console.log(0)这个语句,打印出来 0。

紧接着就是执行 foo 函数,由于 foo 函数是被 async 标记过的,所以当进入该函数的时候,JavaScript 引擎会保存当前的调用栈等信息,然后执行 foo 函数中console.log(1)语句,并打印出 1。

接下来就执行到 foo 函数中的await 100这个语句了,这里是我们分析的重点,因为在执行await 100这个语句时,JavaScript 引擎在背后为我们默默做了太多的事情,那么下面我们就把这个语句拆开,来看看 JavaScript 到底都做了哪些事情。

当执行到await 100时,会默认创建一个 Promise 对象,代码如下所示:

1
2
3
let promise = new Promise((resolve,reject){
resolve(100)
})

在这个 promise 对象创建的过程中,我们可以看到在 executor 函数中调用了 resolve 函数,JavaScript 引擎会将该任务提交给微任务队列。

然后 JavaScript 引擎会暂停当前协程的执行,将主线程的控制权转交给父协程执行,同时会将 promise对象返回给父协程

主线程的控制权已经交给父协程了,这时候父协程要做的一件事是调用 promise.then 来监控 promise 状态的改变。

接下来继续执行父协程的流程,这里我们执行console.log(3),并打印出来 3。随后父协程将执行结束,在结束之前,会进入微任务的检查点,然后执行微任务队列,微任务队列中有resolve(100)的任务等待执行,执行到这里的时候,会触发 promise.then 中的回调函数,如下所示:

1
2
3
4
5
promise.then((value)=>{
// 回调函数被激活后
// 将主线程控制权交给 foo 协程,并将 vaule 值传给协程
})
该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程。

该回调函数被激活以后,会将主线程的控制权交给 foo 函数的协程,并同时将 value 值传给该协程。

foo 协程激活之后,会把刚才的 value 值赋给了变量 a,然后 foo 协程继续执行后续语句,执行完成之后,将控制权归还给父协程。

以上就是 await/async 的执行流程。

nodejs中的时间循环

没必要太过于深究,网上说的也是各不相同

首先上图

image-20211231142902981

先解释一下各个阶段

  1. timers: 这个阶段执行setTimeout()和setInterval()设定的回调,可以认为是设置。
  2. pending callbacks: 执行几乎所有的回调,除了close回调,timer的回调,和setImmediate()的回调。
  3. idle, prepare: 仅内部使用。
  4. poll: 获取新的I/O事件;node会在适当条件下阻塞在这里。
  5. check: 执行setImmediate()设定的回调。
  6. close callbacks: 执行比如socket.on(‘close’, …)的回调。

我们只需要关注timer poll check 这三个阶段即可

每个阶段的详情

*timer

一个timer指定一个下限时间而不是准确时间,在达到这个下限时间后执行回调。在指定时间过后,timers会尽可能早地执行回调,但系统调度或者其它回调的执行可能会延迟它们。

据我观察。timer阶段应该会向某个队列放入到时间的定时器,以便poll阶段调用

注意:技术上来说,poll 阶段控制 timers 什么时候执行

Pending callbacks

执行延迟到下一个循环迭代的 I/O 回调。此阶段对某些系统操作(如 TCP 错误类型)执行回调。例如,如果 TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 *nix 的系统希望等待报告错误。这将被排队以在挂起的回调阶段执行。

*poll 重点分析

poll 阶段的流程

  • 执行 poll 阶段的任务队列。
  • 如果为空了,则检查是否存在已达到时间阈值的计时器(timers),如果有则跳转至timers阶段

没有被调度的计时器时,将会发生以下情况

  • 如果 poll 队列不为空的话,会执行 poll 队列直到清空或者系统回调数达到了与系统相关的硬性限制
  • 如果 poll 队列为空
    • 如果设定了 setImmediate 回调,会直接跳到 check 阶段。
    • 如果没有设定 setImmediate 回调,会阻塞住进程,并等待新的 poll 任务(定时器或者新的I/O事件)加入并立即执行。

来自于charTGp

在 Node.js 中,Poll 阶段阻塞时,不会立即进入 Check 阶段。相反,Node.js 会等待事件循环中的 Poll 阶段完成或者达到一定条件时,才会继续执行 Check 阶段。具体的情况如下:

  1. 当 Poll 阶段有事件到达时:如果在 Poll 阶段有 I/O 事件(例如网络请求或文件读取)到达,事件循环会立即离开 Poll 阶段,处理这些事件的回调函数,并且不会进入 Check 阶段,直接返回到 Timers 阶段,以执行定时器回调函数。
  2. 当 Poll 阶段没有事件到达,但有 setImmediate 回调函数时:如果 Poll 阶段没有待处理的事件,事件循环会继续检查是否有设置了 setImmediate 的回调函数需要执行,如果有,它会离开 Poll 阶段,进入 Check 阶段执行这些回调函数。
  3. 在某些情况下,可能需要 Poll 阶段显式让出控制:有时,开发者可能需要在 Poll 阶段显式让出控制,以便在稍后的事件循环迭代中进入 Check 阶段。这可以通过使用 process.nextTick 来实现。process.nextTick 的回调函数会在当前事件循环迭代的末尾执行,然后才进入下一个迭代的 Check 阶段。

总之,在大多数情况下,当 Poll 阶段没有待处理的事件时,事件循环会立即检查是否有 setImmediate 回调函数需要执行,然后再进入 Check 阶段。这就是为什么 setImmediate 的回调函数有时被描述为 “下一个事件循环迭代中执行” 的原因。但需要注意,setImmediate 回调函数也不能保证立即执行,它仍然受到事件循环的进程调度和其他因素的影响。

*check

这个阶段在 poll 结束后立即执行,setImmediate 的回调会在这里执行。

一般来说,event loop 肯定会进入 poll 阶段,当没有 poll 任务时,会等待新的任务出现,但如果设定了 setImmediate,会直接执行进入下个阶段而不是继续等。

close

如果套接字或处理函数突然关闭(例如 socket.destroy()),则'close' 事件将在这个阶段发出。否则它将通过 process.nextTick() 发出???。

帮助理解的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var fs = require('fs');

function someAsyncOperation (callback) {
// 假设这个任务要消耗 95ms
fs.readFile('/path/to/file', callback);
}

var timeoutScheduled = Date.now();

setTimeout(function () {

var delay = Date.now() - timeoutScheduled;

console.log(delay + "ms have passed since I was scheduled");
}, 100);


// someAsyncOperation要消耗 95 ms 才能完成
someAsyncOperation(function () {

var startCallback = Date.now();

// 消耗 10ms...
while (Date.now() - startCallback < 10) {
; // do nothing
}

});
复制代码

当event loop进入 poll 阶段,它有个空队列(fs.readFile()尚未结束)。所以它会等待剩下的毫秒, 直到最近的timer的下限时间到了。当它等了95ms,fs.readFile()首先结束了,然后它的回调被加到 poll 的队列并执行——这个回调耗时10ms。之后由于没有其它回调在队列里,所以event loop会查看最近达到的timer的 下限时间,然后回到 timers 阶段,执行timer的回调。

所以在示例里,回调被设定 和 回调执行间的间隔是105ms。

setImmediate() vs setTimeout()

现在我们应该知道两者的不同,他们的执行阶段不同,setImmediate() 在 check 阶段,而settimeout 在 poll 阶段执行。但,还不够。来看一下例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// timeout_vs_immediate.js
setTimeout(function timeout () {
console.log('timeout');
},0);

setImmediate(function immediate () {
console.log('immediate');
});
复制代码
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout
复制代码

结果居然是不确定的,why?

还是直接给出解释吧。

  1. 首先进入timer阶段,如果我们的机器性能一般,那么进入timer阶段时,1毫秒可能已经过去了(setTimeout(fn, 0) 等价于setTimeout(fn, 1)),那么setTimeout的回调会首先执行。
  2. 如果没到一毫秒,那么我们可以知道,在check阶段,setImmediate的回调会先执行。

那我们再来一个

1
2
3
4
5
6
7
8
9
10
11
12
// timeout_vs_immediate.js
var fs = require('fs')

fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
复制代码

输出始终为

1
2
3
4
$ node timeout_vs_immediate.js
immediate
timeout
复制代码

这个就很好解释了吧。 fs.readFile 的回调执行是在 poll 阶段。当 fs.readFile 回调执行完毕之后,会直接到 check 阶段,先执行 setImmediate 的回调。

process.nextTick()

nextTick 比较特殊,它有自己的队列,并且,独立于event loop。 它的执行也非常特殊,无论 event loop 处于何种阶段,都会在当前阶段结束的时候清空 nextTick 队列

promise

promiseprocess.nextTick()类似,处在任意阶段的末尾,如果有上述两种任务,都会在当前阶段结束前执行

但是优先级比nextTick低。

https://www.udemy.com/course/nodejs-express-mongodb-bootcamp/learn/lecture/15064744#overview

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const fs = require('fs');

setTimeout(() => {
console.log('setTimeout 1');
}, 0);

setImmediate(() => {
console.log('setImmediate 1');
});

fs.readFile('./test-file.txt', () => {
console.log('readFile');
setTimeout(() => {
console.log('setTimeout 2');
}, 0);

setTimeout(() => {
console.log('setTimeout 3');
}, 3000);

setImmediate(() => {
console.log('setImmediate 2');
});

Promise.resolve().then(() => {
console.log('promise 1');
});

process.nextTick(() => {
console.log('nextTick 1');
});

Promise.resolve().then(() => {
console.log('promise 2');
});
});

// nextTick 会在每个阶段结束前运行
// promise 优先级不如nextTick

// setImmediate 1
// setTimeout 1
// readFile
// nextTick 1
// promise 1
// promise 2
// setImmediate 2
// setTimeout 2
// setTimeout 3

简化版

一句话总结

timers(定时器) => poll(i/o) => check(setImmediate),

promiseprocess.nextTick()类似,处在任意阶段的末尾,如果有上述两种任务,都会在当前阶段结束前执行

腾讯

node官网

掘金

介绍几个主要的插件

tree-shaking

  • 删除没有使用的代码
  • 基于ES6的import export
  • sideEffects选项(忽略设置,一般用于忽略一些css 或者 修改全局作用域的js)
  • babel 需要设置 modules:false(保留es6语法)
  • webpack 4 生产模式默认开启

Terser-webpack-plugin

  • 压缩js代码,webpack 4 中后期替代了uglifyjs-webpack-plugin
  • 支持ES6语法

scope hoisting(作用域提升) ModuleConcatenationPlugin

  • 代码体积减少
  • 提高执行效率
  • 同样需要babel的modules配置
  • 基于ES6 import export
  • webpack 4 生产环境 默认开启

没有启用作用域提升

image-20210124154018793

启用作用域提升之后,会做一个合并

image-20210124154325923

code spiliting 代码分割 splitchunks

  • 把单个bundle文件拆分成若干个小bundles/chunks
  • 缩短首屏加载时间
  • 相当重要的优化选项,详细介绍见收藏夹
    对参数做一个小小的解释
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
optimization: {
splitChunks: {
chunks: 'async', // async 异步 import() all 同步异步
minSize: 30000, // 最小体积 3000B
maxSize: 0,
minChunks: 1, // 最少被引用了一次
maxAsyncRequests: 5, // 限制异步模块内部的并行最大请求数的
maxInitialRequests: 3,
automaticNameDelimiter: '~',
name: true,
cacheGroups: { // cacheGroups
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true
}
}
}
}
chunk
  • chunk是webpack根据功能拆分出来的,包含三种情况:
    1. 通过import()动态引入的代码
    2. 通过splitChunks拆分出来的代码
    3. 你的项目入口(entry)
cacheGroups

splitChunks就是根据cacheGroups去拆分模块的,包括之前说的chunks属性和之后要介绍的种种属性其实都是对缓存组进行配置的

  • 入口文件本身算一个请求
  • 如果入口里面有动态加载得模块这个不算在内
  • 通过runtimeChunk拆分出的runtime不算在内
  • 只算js文件的请求,css不算在内
  • 如果同时又两个模块满足cacheGroup的规则要进行拆分,但是maxInitialRequests的值只能允许再拆分一个模块,那尺寸更大的模块会被拆分出来
maxInitialRequests

表示允许入口并行加载的最大请求数,之所以有这个配置也是为了对拆分数量进行限制,不至于拆分出太多模块导致请求数量过多而得不偿失

maxAsyncRequests

用来限制异步模块内部的并行最大请求数的,说白了你可以理解为是每个import()它里面的最大并行请求数量

  • import()文件本身算一个请求
  • 并不算js以外的公共资源请求比如css
  • 如果同时有两个模块满足cacheGroup的规则要进行拆分,但是maxInitialRequests的值只能允许再拆分一个模块,那尺寸更大的模块会被拆分出来
runtimeChunk

感觉主要的作用是为了优化持久化缓存

形如import('abc').then(res=>{})这种异步加载的代码,在webpack中即为运行时代码。在VueCli工程中常见的异步加载路由即为runtime代码

设置runtimeChunk是将包含chunks 映射关系的 list单独从 app.js里提取出来,因为每一个 chunk 的 id 基本都是基于内容 hash 出来的,所以每次改动都会影响它,如果不将它提取出来的话,等于app.js每次都会改变。缓存就失效了。设置runtimeChunk之后,webpack就会生成一个个runtime~xxx.js的文件。
然后每次更改所谓的运行时代码文件时,打包构建时app.js的hash值是不会改变的。如果每次项目更新都会更改app.js的hash值,那么用户端浏览器每次都需要重新加载变化的app.js,如果项目大切优化分包没做好的话会导致第一次加载很耗时,导致用户体验变差。现在设置了runtimeChunk,就解决了这样的问题。所以这样做的目的是避免文件的频繁变更导致浏览器缓存失效,所以其是更好的利用缓存。提升用户体验。
链接:https://www.jianshu.com/p/714ce38b9fdc

Minificaiton 资源压缩

  • terser 压缩js
  • Mini-css-extract-plugin 压缩css
  • HtmlWebpackPlugin 压缩html

可持续化缓存

主要是借助hash,content-hash

babel 7 优化配置

  • 在需要的地方引入polyfill
    • useBuiltIns: “usage”
  • 辅助函数的复用
    • 配置一个@babel/plugin-transform-runtime

Webpack 依赖优化

noParse

  • 提高构建速度
  • 直接通知webpack忽略较大的库
  • 被忽略的库不能有import require define的引入方式
  • 例如 lodash

DllPlugin(搭配DllReferencePlugin)

  • 避免打包时对不变的库重复构建
  • 提高构建速度
  • 不会对打包后的文件造成影响
  • 开发环境的web-server 用这个更加的合适,可以提高热部署的速度

happypack(多线程打包)

webpack 监测与分析

  • Stats 分析与可视化图
  • webpack-bundle-analyzer 进行体积分析
  • Speed-measure-webpack-plugin 速度分析

类型转换内容相当繁杂

参考

冴羽blog-显式类型转换

​ 没有针对不同类型的对象做转换函数分析 valueOf toString,js高程第五章有介绍

冴羽blog-隐式类型转换

javaScript权威指南

持续补充,后期整理格式

隐式类型转换

来自于js高程第四版

记住 Number() 转换规则很重要

一元加和减(特指放在头部)

如果将一元加应用到非数值,则会执行与使用 **Number()**转型函数一样的类型转换。
Number转换规则如下:

  • 布尔值,true 转换为 1,false 转换为 0。
  • 数值,直接返回。
  • null,返回 0。
  • undefined,返回 NaN。
  • 字符串,应用以下规则。
    - 如果字符串包含数值字符,包括数值字符前面带加、减号的情况,则转换为一个十进制数值。 因此,Number(“1”)返回 1,Number(“123”)返回 123,Number(“011”)返回 11(忽略前面 的零)。
    - 如果字符串包含有效的浮点值格式如”1.1”,则会转换为相应的浮点值(同样,忽略前面的零)。
    - 如果字符串包含有效的十六进制格式如”0xf”,则会转换为与该十六进制值对应的十进制整 数值。
    - 如果是空字符串(不包含字符),则返回 0。
    - 如果字符串包含除上述情况之外的其他字符,则返回 NaN。

    • Number("a1"); NaN
      let num1 = Number("Hello world!"); // NaN 
      let num2 = Number(""); // 0 
      let num3 = Number("000011"); // 11 
      let num4 = Number(true); // 1 
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44

      - 对象,调用 valueOf()方法,并按照上述规则转换返回的值。如果转换结果是 NaN,则调用 toString()方法,再按照转换字符串的规则转换。

      **`-` 、`--`、 `++` 会先执行相同转换后,然后再进行运算操作**

      ## 关于运算操作符

      对于NaN +-0 +-Infinity 的操作,这里不深究

      ## 乘性操作符

      ECMAScript定义了3个乘性操作符:乘法、除法和取模,如果乘性操作符有 不是数值的操作数,则该操作数会在后台被使用 `Number()` 转型函数转换为数值。

      ## 加性操作符

      ### 加法操作符

      加法操作符(+)用于求两个数的和.

      - 如果有一个操作数是字符串,则要应用如下规则:
      - 如果两个操作数都是字符串,则将第二个字符串拼接到第一个字符串后面
      - 如果**只有一个操作数是字符串,则将另一个操作数转换为字符串**,再将两个字符串拼接在一起。
      - 如果有任一操作数是对象、数值或布尔值,则调用它们的 toString()方法以获取字符串,然后再应用前面的关于字符串的规则。对于 `undefined` 和 `null`,则调用 `String()`函数,分别获取 "undefined" 和 "null"。

      ### 减法操作符

      转换规则如下:

      - 如果有任一操作数是字符串、布尔值、null 或 undefined,则先在后台使用 Number()将其转换为数值,然后再根据前面的规则执行数学运算。如果转换结果是 NaN,则减法计算的结果是 NaN。
      - 如果有任一操作数是对象,则调用其 valueOf()方法取得表示它的数值。如果该值是 NaN,则 减法计算的结果是 NaN。如果对象没有 valueOf()方法,则调用其 toString()方法,然后再将得到的字符串转换为数值

      ## 关系操作符

      关系操作符执行比较两个值的操作,包括小于(<)、大于(>)、小于等于(<=)和大于等于(>=),用法跟数学课上学的一样。这几个操作符都返回布尔值
      与ECMAScript中的其他操作符一样,在将它们应用到不同数据类型时也会发生类型转换和其他行为:

      - 如果操作数都是数值,则执行数值比较。
      - 如果操作数都是字符串,则逐个比较字符串中对应字符的编码。
      - 如果有任一操作数是数值,则将另一个操作数转换为数值,执行数值比较。
      - 如果有任一操作数是对象,则调用其 valueOf()方法,取得结果后再根据前面的规则执行比较。如果没有 valueOf()操作符,则调用 toString()方法,取得结果后再根据前面的规则执行比较。
      - 如果有任一操作数是布尔值,则将其转换为数值再执行比较。

      > 任何关系 操作符在涉及比较 NaN 时都返回 false
      >
      > let result1 = NaN < 3; // false > let result2 = NaN >= 3; // false > ```

相等操作符

等于和不等于

ECMAScript中的等于操作符用两个等于号(==)表示,如果操作数相等,则会返回 true。不等于 操作符用叹号和等于号(!=)表示,如果两个操作数不相等,则会返回 true。这两个操作符都会先进 行类型转换(通常称为强制类型转换)再确定操作数是否相等。
在转换操作数的类型时,相等和不相等操作符遵循如下规则:

  • 如果任一操作数是布尔值,则将其转换为数值再比较是否相等。false 转换为 0,true 转换 为 1。
  • 如果一个操作数是字符串,另一个操作数是数值,则尝试将字符串转换为数值,再比较是否相等。
  • 如果一个操作数是对象,另一个操作数不是,则调用对象的 valueOf()方法取得其原始值,再根据前面的规则进行比较。
  • null 和 undefined 相等。
  • null 和 undefined 不能转换为其他类型的值再进行比较。
  • 如果有任一操作数是 NaN,则相等操作符返回 false,不相等操作符返回 true。记住:即使两个操作数都是 NaN,相等操作符也返回 false,因为按照规则,NaN 不等于 NaN。
  • 如果两个操作数都是对象,则比较它们是不是同一个对象。

全等和不全等

全等和不全等操作符与相等和不相等操作符类似,只不过它们在比较相等时不转换操作数。

图片优化方案

image-20210123111451889

图片格式比较

jpg/jpeg 很高的压缩比,较高的图片质量,纹理边缘表现差

png 支持透明,图片质量较高,纹理边缘表现好,图片大小较大

webp 谷歌推出的格式,兼容性不太好,兼具png 、jpg的优点

gif

jpeg

png

webp

图片的懒加载

原生的图片懒加载方案
1
loading="lazy"
第三方图片懒加载方案

verlok/lazyload

Yall.js

Blazy

vue-lazyload

使用渐进式图片

渐进式 jpeg progressive jpeg

使用响应式图片

1
<img src="100.png" sizes="50%" srcset="100.png 100w, 200.png 200w, 400.png 400w">

sizes

  这个属性可以写一些css,例如“100px”,“50vm”,‘20rem”,”30vm”,甚至是媒体查询 “(min-width: 600px) 25vw, (min-width: 500px) 50vw, 100vm”。

srcset

  顾名思义,就是一堆图片来源的预设。例如:“100.png 100w”, 表示预设 100.png 这张图片,并且告诉浏览器,这张图片的宽度是100。

  我们来看看mdn的描述:

img

image-20220323100631910

前言

JavaScript 是一种弱类型或者说动态类型,这就意味着你不需要提前声明变量的类型,在程序运行的过程中,类型会被自动确定。这就意味着你可以使用同一个变量保存不同类型的数据:

1
2
3
4
var data = 5 // data is Number now
data = '5' // data is String now
data = true // data is Boolean now
.......

相信不管是在学习还是平常写业务的过程中,或多或少的都会碰到类似于— 如何判断数据类型 的这种问题。尤其是在面试中,经常被问到 — 请说出判断数组的几种方法 你知道判断数据类型有哪几种方法等等。

虽然看起来仅仅只是判断数据类型的方法,但是涉及到数据类型原型链等各种js基础,话不多说,直接开始吧。

数据类型

最新的 ECMAScript 标准定义了 8 种数据类型:

  • 7种原始类型:
    • Boolean
    • Null
    • Undefined
    • Number
    • BigInt
    • String
    • Symbol
  • Object

原始值

除Object 以外的所有类型(基本类型)都是不可以变的(值本身无法被改变) 例如:js中字符串是不可变的(js对字符串的操作返回了一个新字符串,但是原始字符串并没有被改变),我们称这些类型的值为“原始值”。

Boolean

对于布尔类型,永远只有truefalse两个值。

Null

null 是一个字面量,不像 undefined ,它不是一个全局对象的一个属性。null 是表示缺少的标识,指示变量未指向任何对象。把 null 作为尚未创建的对象,也许更好理解。

在 API 中,null 常在返回类型应是一个对象,但没有关联的值的地方使用。

Undefined

一个没有被赋值的变量会有个默认值 undefined; undefined是全局对象的一个属性。

Number

根据 ECMAScript 标准,JavaScript 中只有一种数字类型:基于 IEEE 754 标准的双精度 64 位二进制格式的值(-(2^53 -1) 到 2^53 -1)。它并没有为整数给出一种特定的类型。除了能够表示浮点数外,还有一些带符号的值:+Infinity,-Infinity 和 NaN (非数值,Not-a-Number)。

BigInt

使用 BigInt,可以安全地存储和操作大整数. 常常通过在整数末尾附加 n 或调用构造函数来创建的。

1
2
3
4
5
6
const a = BigInt('43243242424242424242342432')
// 43243242424242424242342432n

const b = 43243242424242424242342432n
// 43243242424242424242342432n
复制代码

String

字符串的长度是它的元素的数量。字符串一旦被创建,就不能被修改。但是,可以基于原始字符串的操作来创建新的字符串。例如:

  • String.concat() 拼接字符串

插一个问题:'1'.toString()为什么可以调用?

1
2
3
4
5
6
let a = new Object('1');
a.toString();
console.log('-----a',a); // [String: '1']
a = null
console.log('-----a最终',a); // null
复制代码
  • 第一步:创建Object类实例。注意为什么不是String ?由于Symbol和BigInt的出现,对它们调用new都会报错,目前ES6规范也不建议用new来创建基本类型的包装类。
  • 第二步:调用实例方法。
  • 第三步:执行完方法立即销毁这个实例。

Symbol

Symbol 是ES6新增的一种基本数据类型。我们可以通过调用内置函数 Symbol() 创建,这个函数会动态的生成一个匿名、全局唯一的值。

1
2
3
4
5
6
const a = Symbol();
const b = Symbol();
a === b // false

const c = Symbol('c'); // Symbol(c)
复制代码

Symbol 函数栈不能用 new 命令,因为 Symbol 是原始数据类型,不是对象。可以接受一个字符串作为参数,为新创建的 Symbol 提供描述,用来显示在控制台或者作为字符串的时候使用,便于区分。


Symbol最大的用处就是:避免对象的键被覆盖。

基本数据和引用数据的区别

基本数据类型
  • 按值访问,可操作保存在变量中实际的值
  • 值被保存在 栈内存 中,占据固定大小的空间
引用数据类型
  • 引用类型的值是按引用访问的
  • 保存在堆内存中的对象,不能直接访问操作对象的内存空间

回归正题,下面来说说判断数据类型的方法👇


typeof

1
2
3
4
5
6
7
8
9
10
11
typeof '5' // string
typeof 5 // number
typeof null // object
typeof undefined // undefined
typeof true // boolean
typeof Symbol('5') // symbol
typeof 5n // bigint
typeof new Object(); // object
typeof new Function(); // function

复制代码

上面的例子,对于基本数据类型来说,除了null返回的是object,其他都可返回正确的类型。 调用null为空,是因为

  • null被认为是一个空对象,因此返回了object
  • 因为任何对象都会被转化为二进制,null转为二进制则表示全为0,如果前三个均为0,js就会把它当作是对象,这是js早期遗留下来的bug

所以typeof

  • 适用于判断(除null)基础类型,
  • 判断引用类型,除了function 全返回object类型

instanceof

  • 只能用来判断变量的原型链上是否有构造函数的prototype属性(两个对象是否属于原型链的关系),不一定能获取对象的具体类型
  • Instanceof 不适用判断原始类型的值,只能用于判断对象是否从属关系
1
2
3
4
5
6
7
8
9
[] instanceof Array; // true
[] instanceof Object; // true

function Person() {};
const person = new Person();

person instanceof Person; // true
person instanceof Object; // true
复制代码

首先来分析一下,为什么 [] instanceof Array 为true。

  • 首先,[].proto 的原型 是指向Array.prototype 的,说明两个对象是属于同一条原型链的,返回true
  • 同理,从代码中可以得知person instanceof Person也是返回true的,那么为什么person instanceof Object也为true呢?
  • 基于原型链的原理:从实例对象的构造函数的原型开始向上寻找,构造函数的原型又有其原型,一直向上找,直到找到原型链的顶端Object.prototype为止。如果没有,则返回null
  • 可以看出,person和Object是属于原型链的关系,所以返回true

img

1
2
3
4
5
6
7
8
9
10
11
注意:空对象{}的判断问题
let obj1 = {}
console.log(obj1 instanceof Object) // true

let obj2 = Object.create(null)
console.log(obj2 instanceof Object) // false

let obj3 = Object.create({})
console.log(obj3 instanceof Object) // true

复制代码

constructor

原理:每一个实例对象都可通过constructor来访问它的构造函数,其实也是根据原型链的原理来的。

1
2
3
4
5
6
7
8
'5'.__proto__.constructor === String // true
[5].__proto__.constructor === Array // true

undefined.__proto__.constructor // Cannot read property '__proto__' of undefined

null.__proto__.constructor // Cannot read property '__proto__' of undefined

复制代码

由于undefined和null是无效的对象,因此是没有constructor属性的,这两个值不能用这种方法判断.

toString

  • Object.prototype.toString方法返回对象的类型字符串,因此可用来判断一个值的类型。
  • 因为实例对象有可能会自定义toString方法,会覆盖Object.prototype.toString,所以在使用时,最好加上call
  • 所有的数据类型都可以使用此方法进行检测,且非常精准

关于原理

1
2
3
4
5
6
7
8
9
10
11
12
13
Object.prototype.toString.call('5') // [object String]
Object.prototype.toString.call(5) // [object Number]
Object.prototype.toString.call([5]) // [object Array]
Object.prototype.toString.call(true) // [object Boolean]
Object.prototype.toString.call(undefined) // [object Undefined]
Object.prototype.toString.call(null) // [object Null]
Object.prototype.toString.call(new Function()); // [object Function]
Object.prototype.toString.call(new Date()); // [object Date]
Object.prototype.toString.call(new RegExp()); // [object RegExp]
Object.prototype.toString.call(new Error()); // [object Error]
Object.prototype.toString.call({df:1}); // [object Object]

复制代码

总结

  • typeof 适合基本类型和function类型的检测,无法判断null与object
  • instanceof 适合自定义对象,也可以用来检测原生对象,在不同的iframe 和 window间检测时失效,还需要注意Object.create(null)对象的问题
  • constructor 基本能判断所有类型,除了null和undefined,但是constructor容易被修改,也不能跨iframe使用
  • tostring能判断所有类型,可将其封装为全能的DataType()判断所有数据类型

字体优化

web字体终极优化方案

什么是FOIT 和 FOUT(不可避免的问题)

  • 字体未下载完成时,浏览器隐藏或自动降级,导致字体闪烁
  • Flash Of Invisible Text
  • Flash Of Unstyle Text

font-display(推荐使用)

兼容性见下图

image-20210124104344312

font-display有五个属性

  • auto

    字体显示策略由用户代理定义。

  • block

    block给予字体一个较短的阻塞时间(大多数情况下推荐使用 3s)和无限大的交换时间。换言之,如果字体未加载完成,浏览器将首先绘制“隐形”文本;一旦字体加载完成,立即切换字体。为此,浏览器将创建一个匿名字体,其类型与所选字体相似,但所有字形都不含“墨水”。使用特定字体渲染文本之后页面方才可用,只有这种情况下才应该使用 block

  • swap

    使用 swap,则阻塞阶段时间为 0,交换阶段时间无限大。也就是说,如果字体没有完成加载,浏览器会立即绘制文字,一旦字体加载成功,立即切换字体。与 block 类似,如果使用特定字体渲染文本对页面很重要,且使用其他字体渲染仍将显示正确的信息,才应使用 swap。Logo 文字就很适合使用 swap,因为以合理的后备字体显示公司名称仍将正确传递信息,而且最终会以官方字体的样式展现。

  • Fallback

    使用 fallback时,阻塞阶段时间将非常小(多数情况下推荐小于 100ms),交换阶段也比较短(多数情况下建议使用 3 秒钟)。换言之,如果字体没有加载,则首先会使用后备字体渲染。一旦加载成功,就会切换字体。但如果等待时间过久,则页面将一直使用后备字体。如果希望用户尽快开始阅读,而且不因新字体的载入导致文本样式发生变动而干扰用户体验,fallback 是一个很好的选择。举个例子,正文文本就符合这个条件。

  • optional

​ 使用 optional 时,阻塞阶段时间会非常小(多数情况下建议低于 100ms),交换阶段时间为 0。与 fallback 类似,如果字体能够为页面效果增色不少,但并非特别重要时,使用 optional 正好。使用 optional 时,将由 浏览器来决定是否开始下载字体。可以不下载,也可以给予字体较低的优先级,一切取决于浏览器是否认为 对用户最有利。当用户处于弱网络下,这是非常有用的,下载字体可能并非对资源最好的利用。

属性区分

image-20210124104835219

例子

1
2
3
4
5
6
7
8
@ font-face {
font-family:ExampleFont;
src:url(/path/to/fonts/examplefont.woff)format('woff'),
url(/path/to/fonts/examplefont.eot)format('eot');
font-weight:400;
font-style:normal;
font-display:fallback;
}

字体拆分

unicode-range 的作用是为@font-face所设置的字体限定一个应用范围,使用unicode编码来设置范围

可以解决:需求:提供了两种字体文件,要求页面中中文使用方正兰亭黑体,英文使用BlaBlaSans,从而实现中英文使用不同字体。

tips:你希望数字英文是Helvetica字体,中文是苹方或微软雅黑,直接把英文字体放在前面就可以了!

1
2
3
.font {
font-family: Helvetica, 'Pingfang SC', 'microsoft yahei';
}

据我所知,这些英文字体是没有中文字符集映射的,也就是,英文字体实际上对中文是没有任何作用的。考虑到font-family的字体解析是从前往后依次的,所以,自然而然上面的代码数字英文是Helvetica字体,中文是苹方或微软雅黑,完全不需要使用unicode-range做吃力不讨好的事情。

unicode-range适合使用的场景究竟是什么呢?

在我看来,是对中文内容中的某部分中文字符做特殊字体处理,或者是英文字体中部分字符做特殊字体处理,这个才是适合的。比方说,上面使用宋体引号的案例,因为都是中文字体,因此,才有使用unicode-range的价值。

中文汉字unicode编码范围整理demo

Ajax + base64 (不如上述方式,了解)

  • 可以解决兼容性问题
  • 缺点:缓存问题

font-spider

一个本地工具,就是把字体文件中 我们会使用到的文字的样式提取出来
大致流程:

  • 全局安装font-spider
  • 新建一个html,写入我们使用的文字,并且设置我们要压缩的字体
  • 使用终端运行提取命令,这样我们就得到了精简的ttf文件

Fontmin

fontmin

和font-spider 功能类似

梳理浏览器渲染流程

首先简单了解一下浏览器请求、加载、渲染一个页面的大致过程:

  • DNS 查询
  • TCP 连接
  • HTTP 请求即响应
  • 服务器响应
  • 客户端渲染

这里主要将客户端渲染展开梳理一下,从浏览器器内核拿到内容(渲染线程接收请求,加载网页并渲染网页),渲染大概可以划分成以下几个步骤:

  • 解析html建立dom树
  • 解析css构建render树(将CSS代码解析成树形的数据结构,然后结合DOM合并成render树)
  • 布局render树(Layout/reflow),负责各元素尺寸、位置的计算
  • 绘制render树(paint),绘制页面像素信息
  • 浏览器会将各层的信息发送给GPU(GPU进程:最多一个,用于3D绘制等),GPU会将各层合成(composite),显示在屏幕上。

参考一张图(webkit渲染主要流程):

图片描述

这里先解释一下几个概念,方便大家理解:

  DOM Tree:浏览器将HTML解析成树形的数据结构。

  CSS Rule Tree:浏览器将CSS解析成树形的数据结构。

  Render Tree: DOM和CSSOM合并后生成Render Tree。

  layout: 有了Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的CSS定义以及他们的从属关系,从而去计算出每个节点在屏幕中的位置。

  painting: 按照算出来的规则,通过显卡,把内容画到屏幕上。

  reflow(回流):当浏览器发现某个部分发生了点变化影响了布局,需要倒回去重新渲染,内行称这个回退的过程叫 reflow。reflow 会从 这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置。reflow 几乎是无法避免的。现在界面上流行的一些效果,比如树状目录的折叠、展开(实质上是元素的显 示与隐藏)等,都将引起浏览器的 reflow。鼠标滑过、点击……只要这些行为引起了页面上某些元素的占位面积、定位方式、边距等属性的变化,都会引起它内部、周围甚至整个页面的重新渲 染。通常我们都无法预估浏览器到底会 reflow 哪一部分的代码,它们都彼此相互影响着。

  repaint(重绘):改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。

注意:

  1. display:none 的节点不会被加入Render Tree,而visibility: hidden
    则会,所以,如果某个节点最开始是不显示的,设为display:none是更优的。
  2. display:none 会触发 reflow,而 visibility:hidden 只会触发 repaint,因为没有发现位置变化。
  3. 有些情况下,比如修改了元素的样式,浏览器并不会立刻reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次reflow,这又叫异步 reflow 或增量异步 reflow。但是在有些情况下,比如resize窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

再参考一张图理解一下:

图片描述

细致分离两个环节,其他环节参考上述概念注解:

JavaScript:JavaScript实现动画效果,DOM元素操作等。
Composite(渲染层合并):对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

在实际场景下,大致会出现三种常见的渲染流程(Layout和Paint步骤是可避免的,可参考上一张图的注意部分理解):

图片描述


Composite

了解层

注意:首先说明,这里讨论的是 WebKit,描述的是 Chrome 的实现细节,而并非是 web 平台的功能,因此这里介绍的内容不一定适用于其他浏览器。

  • Chrome 拥有两套不同的渲染路径(rendering path):硬件加速路径和旧软件路径(older software path)
  • Chrome 中有不同类型的层: RenderLayer(负责 DOM 子树)和GraphicsLayer(负责 RenderLayer的子树),只有 GraphicsLayer 是作为纹理(texture)上传给GPU的。
  • 什么是纹理?可以把它想象成一个从主存储器(例如 RAM)移动到图像存储器(例如 GPU 中的 VRAM)的位图图像(bitmapimage)
  • Chrome 使用纹理来从 GPU上获得大块的页面内容。通过将纹理应用到一个非常简单的矩形网格就能很容易匹配不同的位置(position)和变形(transformation)。这也就是3DCSS 的工作原理,它对于快速滚动也十分有效。

整个图:

图片描述

在 Chrome 中其实有几种不同的层类型:

  • RenderLayers 渲染层,这是负责对应 DOM 子树
  • GraphicsLayers 图形层,这是负责对应 RenderLayers子树。

在浏览器渲染流程中提到了composite概念,在 DOM 树中每个节点都会对应一个 LayoutObject,当他们的 LayoutObject 处于相同的坐标空间时,就会形成一个 RenderLayers ,也就是渲染层。RenderLayers 来保证页面元素以正确的顺序合成,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示。

某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。

而每个GraphicsLayer(合成层单独拥有的图层) 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后显示到屏幕上。

如何变成合成层

合成层创建标准

什么情况下能使元素获得自己的层?虽然 Chrome的启发式方法(heuristic)随着时间在不断发展进步,但是从目前来说,满足以下任意情况便会创建层:

  • 3D 或透视变换(perspective transform) CSS 属性
  • 使用加速视频解码的
  • (WebGL) 上下文或加速的 2D 上下文的 元素
  • 混合插件(如 Flash)
  • 对自己的 opacity 做 CSS动画或使用一个动画变换的元素
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)
  • 元素有一个z-index较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)

合成层的优点

淘宝的栗子举的很详细,值得一看,里面提到了一旦renderLayer提升为了合成层就会有自己的绘图上下文,并且会开启硬件加速,有利于性能提升,里面列举了一些特点

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

注意:

  1. 提升到合成层后合成层的位图会交GPU处理,但请注意,仅仅只是合成的处理(把绘图上下文的位图输出进行组合)需要用到GPU,生成合成层的位图处理(绘图上下文的工作)是需要CPU。
  2. 当需要repaint的时候可以只repaint本身,不影响其他层,但是paint之前还有style, layout,那就意味着即使合成层只是repaint了自己,但style和layout本身就很占用时间。
  3. 仅仅是transform和opacity不会引发layout 和paint,那么其他的属性不确定。

总结合成层的优势:一般一个元素开启硬件加速后会变成合成层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。

性能优化点:

  1. 提升动画效果的元素 合成层的好处是不会影响到其他元素的绘制,因此,为了减少动画元素对其他元素的影响,从而减少paint,我们需要把动画效果中的元素提升为合成层。 提升合成层的最好方式是使用 CSS 的 will-change属性。从上一节合成层产生原因中,可以知道 will-change 设置为opacity、transform、top、left、bottom、right 可以将元素提升为合成层。
  2. 使用 transform 或者 opacity 来实现动画效果, 这样只需要做合成层的合并就好了。
  3. 减少绘制区域 对于不需要重新绘制的区域应尽量避免绘制,以减少绘制区域,比如一个 fix 在页面顶部的固定不变的导航header,在页面内容某个区域 repaint 时,整个屏幕包括 fix 的 header 也会被重绘。而对于固定不变的区域,我们期望其并不会被重绘,因此可以通过之前的方法,将其提升为独立的合成层。减少绘制区域,需要仔细分析页面,区分绘制区域,减少重绘区域甚至避免重绘。

利用合成层可能踩到的坑

  1. 合成层占用内存的问题
  2. 层爆炸,由于某些原因可能导致产生大量不在预期内的合成层,虽然有浏览器的层压缩机制,但是也有很多无法进行压缩的情况,这就可能出现层爆炸的现象(简单理解就是,很多不需要提升为合成层的元素因为某些不当操作成为了合成层)。解决层爆炸的问题,最佳方案是打破 overlap 的条件,也就是说让其他元素不要和合成层元素重叠。简单直接的方式:使用3D硬件加速提升动画性能时,最好给元素增加一个z-index属性,人为干扰合成的排序,可以有效减少chrome创建不必要的合成层,提升渲染性能,移动端优化效果尤为明显。 在这篇文章中的demo可以看出其中厉害。

用chremo打开demo页面后,开启浏览器的开发者模式,再按照如图操作打开查看工具:

梳理浏览器渲染流程

首先简单了解一下浏览器请求、加载、渲染一个页面的大致过程:

  • DNS 查询
  • TCP 连接
  • HTTP 请求即响应
  • 服务器响应
  • 客户端渲染

这里主要将客户端渲染展开梳理一下,从浏览器器内核拿到内容(渲染线程接收请求,加载网页并渲染网页),渲染大概可以划分成以下几个步骤:

  • 解析html建立dom树
  • 解析css构建render树(将CSS代码解析成树形的数据结构,然后结合DOM合并成render树)
  • 布局render树(Layout/reflow),负责各元素尺寸、位置的计算
  • 绘制render树(paint),绘制页面像素信息
  • 浏览器会将各层的信息发送给GPU(GPU进程:最多一个,用于3D绘制等),GPU会将各层合成(composite),显示在屏幕上。

参考一张图(webkit渲染主要流程):

图片描述

这里先解释一下几个概念,方便大家理解:

  DOM Tree:浏览器将HTML解析成树形的数据结构。

  CSS Rule Tree:浏览器将CSS解析成树形的数据结构。

  Render Tree: DOM和CSSOM合并后生成Render Tree。

  layout: 有了Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的CSS定义以及他们的从属关系,从而去计算出每个节点在屏幕中的位置。

  painting: 按照算出来的规则,通过显卡,把内容画到屏幕上。

  reflow(回流):当浏览器发现某个部分发生了点变化影响了布局,需要倒回去重新渲染,内行称这个回退的过程叫 reflow。reflow 会从 这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置。reflow 几乎是无法避免的。现在界面上流行的一些效果,比如树状目录的折叠、展开(实质上是元素的显 示与隐藏)等,都将引起浏览器的 reflow。鼠标滑过、点击……只要这些行为引起了页面上某些元素的占位面积、定位方式、边距等属性的变化,都会引起它内部、周围甚至整个页面的重新渲 染。通常我们都无法预估浏览器到底会 reflow 哪一部分的代码,它们都彼此相互影响着。

  repaint(重绘):改变某个元素的背景色、文字颜色、边框颜色等等不影响它周围或内部布局的属性时,屏幕的一部分要重画,但是元素的几何尺寸没有变。

注意:

  1. display:none 的节点不会被加入Render Tree,而visibility: hidden
    则会,所以,如果某个节点最开始是不显示的,设为display:none是更优的。
  2. display:none 会触发 reflow,而 visibility:hidden 只会触发 repaint,因为没有发现位置变化。
  3. 有些情况下,比如修改了元素的样式,浏览器并不会立刻reflow 或 repaint 一次,而是会把这样的操作积攒一批,然后做一次reflow,这又叫异步 reflow 或增量异步 reflow。但是在有些情况下,比如resize窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

再参考一张图理解一下:

图片描述

细致分离两个环节,其他环节参考上述概念注解:

JavaScript:JavaScript实现动画效果,DOM元素操作等。
Composite(渲染层合并):对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。对于有位置重叠的元素的页面,这个过程尤其重要,因为一旦图层的合并顺序出错,将会导致元素显示异常。

在实际场景下,大致会出现三种常见的渲染流程(Layout和Paint步骤是可避免的,可参考上一张图的注意部分理解):

图片描述


Composite

了解层

注意:首先说明,这里讨论的是 WebKit,描述的是 Chrome 的实现细节,而并非是 web 平台的功能,因此这里介绍的内容不一定适用于其他浏览器。

  • Chrome 拥有两套不同的渲染路径(rendering path):硬件加速路径和旧软件路径(older software path)
  • Chrome 中有不同类型的层: RenderLayer(负责 DOM 子树)和GraphicsLayer(负责 RenderLayer的子树),只有 GraphicsLayer 是作为纹理(texture)上传给GPU的。
  • 什么是纹理?可以把它想象成一个从主存储器(例如 RAM)移动到图像存储器(例如 GPU 中的 VRAM)的位图图像(bitmapimage)
  • Chrome 使用纹理来从 GPU上获得大块的页面内容。通过将纹理应用到一个非常简单的矩形网格就能很容易匹配不同的位置(position)和变形(transformation)。这也就是3DCSS 的工作原理,它对于快速滚动也十分有效。

整个图:

图片描述

在 Chrome 中其实有几种不同的层类型:

  • RenderLayers 渲染层,这是负责对应 DOM 子树
  • GraphicsLayers 图形层,这是负责对应 RenderLayers子树。

在浏览器渲染流程中提到了composite概念,在 DOM 树中每个节点都会对应一个 LayoutObject,当他们的 LayoutObject 处于相同的坐标空间时,就会形成一个 RenderLayers ,也就是渲染层。RenderLayers 来保证页面元素以正确的顺序合成,这时候就会出现层合成(composite),从而正确处理透明元素和重叠元素的显示。

某些特殊的渲染层会被认为是合成层(Compositing Layers),合成层拥有单独的 GraphicsLayer,而其他不是合成层的渲染层,则和其第一个拥有 GraphicsLayer 父层公用一个。

而每个GraphicsLayer(合成层单独拥有的图层) 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后显示到屏幕上。

如何变成合成层

合成层创建标准

什么情况下能使元素获得自己的层?虽然 Chrome的启发式方法(heuristic)随着时间在不断发展进步,但是从目前来说,满足以下任意情况便会创建层:

  • 3D 或透视变换(perspective transform) CSS 属性
  • 使用加速视频解码的
  • (WebGL) 上下文或加速的 2D 上下文的 元素
  • 混合插件(如 Flash)
  • 对自己的 opacity 做 CSS动画或使用一个动画变换的元素
  • 拥有加速 CSS 过滤器的元素
  • 元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)
  • 元素有一个z-index较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)

合成层的优点

淘宝的栗子举的很详细,值得一看,里面提到了一旦renderLayer提升为了合成层就会有自己的绘图上下文,并且会开启硬件加速,有利于性能提升,里面列举了一些特点

  • 合成层的位图,会交由 GPU 合成,比 CPU 处理要快
  • 当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层
  • 对于 transform 和 opacity 效果,不会触发 layout 和 paint

注意:

  1. 提升到合成层后合成层的位图会交GPU处理,但请注意,仅仅只是合成的处理(把绘图上下文的位图输出进行组合)需要用到GPU,生成合成层的位图处理(绘图上下文的工作)是需要CPU。
  2. 当需要repaint的时候可以只repaint本身,不影响其他层,但是paint之前还有style, layout,那就意味着即使合成层只是repaint了自己,但style和layout本身就很占用时间。
  3. 仅仅是transform和opacity不会引发layout 和paint,那么其他的属性不确定。

总结合成层的优势:一般一个元素开启硬件加速后会变成合成层,可以独立于普通文档流中,改动后可以避免整个页面重绘,提升性能。

性能优化点:

  1. 提升动画效果的元素 合成层的好处是不会影响到其他元素的绘制,因此,为了减少动画元素对其他元素的影响,从而减少paint,我们需要把动画效果中的元素提升为合成层。 提升合成层的最好方式是使用 CSS 的 will-change属性。从上一节合成层产生原因中,可以知道 will-change 设置为opacity、transform、top、left、bottom、right 可以将元素提升为合成层。
  2. 使用 transform 或者 opacity 来实现动画效果, 这样只需要做合成层的合并就好了。
  3. 减少绘制区域 对于不需要重新绘制的区域应尽量避免绘制,以减少绘制区域,比如一个 fix 在页面顶部的固定不变的导航header,在页面内容某个区域 repaint 时,整个屏幕包括 fix 的 header 也会被重绘。而对于固定不变的区域,我们期望其并不会被重绘,因此可以通过之前的方法,将其提升为独立的合成层。减少绘制区域,需要仔细分析页面,区分绘制区域,减少重绘区域甚至避免重绘。

利用合成层可能踩到的坑

  1. 合成层占用内存的问题
  2. 层爆炸,由于某些原因可能导致产生大量不在预期内的合成层,虽然有浏览器的层压缩机制,但是也有很多无法进行压缩的情况,这就可能出现层爆炸的现象(简单理解就是,很多不需要提升为合成层的元素因为某些不当操作成为了合成层)。解决层爆炸的问题,最佳方案是打破 overlap 的条件,也就是说让其他元素不要和合成层元素重叠。简单直接的方式:使用3D硬件加速提升动画性能时,最好给元素增加一个z-index属性,人为干扰合成的排序,可以有效减少chrome创建不必要的合成层,提升渲染性能,移动端优化效果尤为明显。 在这篇文章中的demo可以看出其中厉害。

用chremo打开demo页面后,开启浏览器的开发者模式,再按照如图操作打开查看工具:

preview

开启 Rendering 的Layer borders后 观察点击为动画元素设置z-index复选框的页面提示变化:

图片描述

上图中可以明显看出:页面中设置了一个h1标题,应用了translate3d动画,使得它被放到composited layer中渲染,然后在这个元素后面创建了2000个list。在不为h1元素设置z-index的情况下,使得本不需要提升到合成层的ul元素下的每个li元素都提升为一个单独合成层(每个li元素的黄色提示边框),最终会导致GPU资源过度消耗页面滑动时很卡,尤其在移动端(安卓)上更加明显。

图片描述

如上图操作选中为动画元素设置z-index,可以看出ul下的每个li都回归到普通渲染层,不再是合成层也就不会消耗GPU资源去渲染,从而达到了优化页面性能优化的目的。

大家可以用支持『硬件加速』的『安卓』手机浏览器测试上述页面,给动画元素加z-index前后的性能差距非常明显。

最后

在实际的前端开发中尤其是移动端开发,很多小伙伴都很喜欢使用类似 translateZ(0)等属性来进行所谓的硬件加速,以提升性能,达到优化页面动态效果的目的,但还是要注意凡事过犹不及,应用硬件加速的同时也要注意到千万别踩坑。
关于合成层的更细致具体的讲解,可以仔细学习下下面的参考文章(尤其是前三篇哦)。
最后祝愿热爱技术的你我始终坚持在探索技术的路上奋力前行!

转载自 https://segmentfault.com/a/1190000014520786

浏览器的渲染过程

本文先从浏览器的渲染过程来从头到尾的讲解一下回流重绘,如果大家想直接看如何减少回流和重绘,可以跳到后面。(这个渲染过程来自MDN

webkit渲染过程

从上面这个图上,我们可以看到,浏览器渲染过程如下:

  1. 解析HTML,生成DOM树,解析CSS,生成CSSOM树
  2. 将DOM树和CSSOM树结合,生成渲染树(Render Tree)
  3. Layout(回流):根据生成的渲染树,进行回流(Layout),得到节点的几何信息(位置,大小)
  4. Painting(重绘):根据渲染树以及回流得到的几何信息,得到节点的绝对像素
  5. Display:将像素发送给GPU,展示在页面上。(这一步其实还有很多内容,比如会在GPU将多个合成层合并为同一个层,并展示在页面中。而css3硬件加速的原理则是新建合成层,这里我们不展开,之后有机会会写一篇博客)

渲染过程看起来很简单,让我们来具体了解下每一步具体做了什么。

生成渲染树

生成渲染树

为了构建渲染树,浏览器主要完成了以下工作:

  1. 从DOM树的根节点开始遍历每个可见节点。
  2. 对于每个可见的节点,找到CSSOM树中对应的规则,并应用它们。
  3. 根据每个可见节点以及其对应的样式,组合生成渲染树。

第一步中,既然说到了要遍历可见的节点,那么我们得先知道,什么节点是不可见的。不可见的节点包括:

  • 一些不会渲染输出的节点,比如script、meta、link等。
  • 一些通过css进行隐藏的节点。比如display:none。注意,利用visibility和opacity隐藏的节点,还是会显示在渲染树上的。只有display:none的节点才不会显示在渲染树上。

注意:渲染树只包含可见的节点

回流

前面我们通过构造渲染树,我们将可见DOM节点以及它对应的样式结合起来,可是我们还需要计算它们在设备视口(viewport)内的确切位置和大小,这个计算的阶段就是回流。

为了弄清每个对象在网站上的确切大小和位置,浏览器从渲染树的根节点开始遍历,我们可以以下面这个实例来表示:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style="width: 50%">
<div style="width: 50%">Hello world!</div>
</div>
</body>
</html>

我们可以看到,第一个div将节点的显示尺寸设置为视口宽度的50%,第二个div将其尺寸设置为父节点的50%。而在回流这个阶段,我们就需要根据视口具体的宽度,将其转为实际的像素值。(如下图)

img

重绘

最终,我们通过构造渲染树和回流阶段,我们知道了哪些节点是可见的,以及可见节点的样式和具体的几何信息(位置、大小),那么我们就可以将渲染树的每个节点都转换为屏幕上的实际像素,这个阶段就叫做重绘节点。

既然知道了浏览器的渲染过程后,我们就来探讨下,何时会发生回流重绘。

何时发生回流重绘

我们前面知道了,回流这一阶段主要是计算节点的位置和几何信息,那么当页面布局和几何信息发生变化的时候,就需要回流。比如以下情况:

  • 添加或删除可见的DOM元素
  • 元素的位置发生变化
  • 元素的尺寸发生变化(包括外边距、内边框、边框大小、高度和宽度等)
  • 内容发生变化,比如文本变化或图片被另一个不同尺寸的图片所替代。
  • 页面一开始渲染的时候(这肯定避免不了)
  • 浏览器的窗口尺寸变化(因为回流是根据视口的大小来计算元素的位置和大小的)

YaHoo!性能小组总结了一些导致回流发生的一些因素:

  1. 调整窗口大小

  2. 改变字体

  3. 增加或者移除样式表

  4. 内容变化,比如用户在 input 框中输入文字, CSS3 动画等

  5. 激活 CSS 伪类,比如 :hover

  6. 操作class属性

  7. 脚本操作DOM

  8. 计算offsetWidthoffsetHeight属性

  9. 设置 style 属性的值

  10. 注意:回流一定会触发重绘,而重绘不一定会回流

根据改变的范围和程度,渲染树中或大或小的部分需要重新计算,有些改变会触发整个页面的重排,比如,滚动条出现的时候或者修改了根节点。

浏览器的优化机制

现代的浏览器都是很聪明的,由于每次重排都会造成额外的计算消耗,因此大多数浏览器都会通过队列化修改并批量执行来优化重排过程。浏览器会将修改操作放入到队列里,直到过了一段时间或者操作达到了一个阈值,才清空队列。但是!当你获取布局信息的操作的时候,会强制队列刷新,比如当你访问以下属性或者使用以下方法:

  • offsetTop、offsetLeft、offsetWidth、offsetHeight
  • scrollTop、scrollLeft、scrollWidth、scrollHeight
  • clientTop、clientLeft、clientWidth、clientHeight
  • getComputedStyle()
  • getBoundingClientRect
  • 具体可以访问这个网站:https://gist.github.com/pauli...点击预览

以上属性和方法都需要返回最新的布局信息,因此浏览器不得不清空队列,触发回流重绘来返回正确的值。因此,我们在修改样式的时候,最好避免使用上面列出的属性,他们都会刷新渲染队列。如果要使用它们,最好将值缓存起来。

减少回流和重绘

好了,到了我们今天的重头戏,前面说了这么多背景和理论知识,接下来让我们谈谈如何减少回流和重绘。

最小化重绘和重排

由于重绘和重排可能代价比较昂贵,因此最好就是可以减少它的发生次数。为了减少发生次数,我们可以合并多次对DOM和样式的修改,然后一次处理掉。考虑这个例子

1
2
3
4
const el = document.getElementById('test');
el.style.padding = '5px';
el.style.borderLeft = '1px';
el.style.borderRight = '2px';

例子中,有三个样式属性被修改了,每一个都会影响元素的几何结构,引起回流。当然,大部分现代浏览器都对其做了优化,因此,只会触发一次重排。但是如果在旧版的浏览器或者在上面代码执行的时候,有其他代码访问了布局信息(上文中的会触发回流的布局信息),那么就会导致三次重排。

因此,我们可以合并所有的改变然后依次处理,比如我们可以采取以下的方式:

  • 使用cssText

    1
    2
    const el = document.getElementById('test');
    el.style.cssText += 'border-left: 1px; border-right: 2px; padding: 5px;';
  • 修改CSS的class

    1
    2
    const el = document.getElementById('test');
    el.className += ' active';

批量修改DOM

当我们需要对DOM对一系列修改的时候,可以通过以下步骤减少回流重绘次数:

  1. 使元素脱离文档流
  2. 对其进行多次修改
  3. 将元素带回到文档中。

该过程的第一步和第三步可能会引起回流,但是经过第一步之后,对DOM的所有修改都不会引起回流,因为它已经不在渲染树了。

有三种方式可以让DOM脱离文档流:

  • 隐藏元素,应用修改,重新显示
  • 使用文档片段(document fragment)在当前DOM之外构建一个子树,再把它拷贝回文档。
  • 将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。

考虑我们要执行一段批量插入节点的代码:

1
2
3
4
5
6
7
8
9
10
11
function appendDataToElement(appendToElement, data) {
let li;
for (let i = 0; i < data.length; i++) {
li = document.createElement('li');
li.textContent = 'text';
appendToElement.appendChild(li);
}
}

const ul = document.getElementById('list');
appendDataToElement(ul, data);

如果我们直接这样执行的话,由于每次循环都会插入一个新的节点,会导致浏览器回流一次。

我们可以使用这三种方式进行优化:

隐藏元素,应用修改,重新显示

这个会在展示和隐藏节点的时候,产生两次重绘

1
2
3
4
5
6
7
8
9
10
11
12
function appendDataToElement(appendToElement, data) {
let li;
for (let i = 0; i < data.length; i++) {
li = document.createElement('li');
li.textContent = 'text';
appendToElement.appendChild(li);
}
}
const ul = document.getElementById('list');
ul.style.display = 'none';
appendDataToElement(ul, data);
ul.style.display = 'block';

使用文档片段(document fragment)在当前DOM之外构建一个子树,再把它拷贝回文档

1
2
3
4
const ul = document.getElementById('list');
const fragment = document.createDocumentFragment();
appendDataToElement(fragment, data);
ul.appendChild(fragment);

将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。

1
2
3
4
const ul = document.getElementById('list');
const clone = ul.cloneNode(true);
appendDataToElement(clone, data);
ul.parentNode.replaceChild(clone, ul);

对于上述那种情况,我写了一个demo来测试修改前和修改后的性能。然而实验结果不是很理想。

原因:原因其实上面也说过了,浏览器会使用队列来储存多次修改,进行优化,所以对这个优化方案,我们其实不用优先考虑。

避免触发同步布局事件

上文我们说过,当我们访问元素的一些属性的时候,会导致浏览器强制清空队列,进行强制同步布局。举个例子,比如说我们想将一个p标签数组的宽度赋值为一个元素的宽度,我们可能写出这样的代码:

1
2
3
4
5
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = box.offsetWidth + 'px';
}
}

这段代码看上去是没有什么问题,可是其实会造成很大的性能问题。在每次循环的时候,都读取了box的一个offsetWidth属性值,然后利用它来更新p标签的width属性。这就导致了每一次循环的时候,浏览器都必须先使上一次循环中的样式更新操作生效,才能响应本次循环的样式读取操作。每一次循环都会强制浏览器刷新队列。我们可以优化为:

1
2
3
4
5
6
const width = box.offsetWidth;
function initP() {
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = width + 'px';
}
}

同样,我也写了个demo来比较两者的性能差异。你可以自己点开这个demo体验下。这个对比差距就比较明显。

对于复杂动画效果,使用绝对定位让其脱离文档流

对于复杂动画效果,由于会经常的引起回流重绘,因此,我们可以使用绝对定位,让它脱离文档流。否则会引起父元素以及后续元素频繁的回流。这个我们就直接上个例子

打开这个例子后,我们可以打开控制台,控制台上会输出当前的帧数(虽然不准)。

image-20181210223750055

从上图中,我们可以看到,帧数一直都没到60。这个时候,只要我们点击一下那个按钮,把这个元素设置为绝对定位,帧数就可以稳定60。

css3硬件加速(GPU加速)

比起考虑如何减少回流重绘,我们更期望的是,根本不要回流重绘。这个时候,css3硬件加速就闪亮登场啦!!

划重点:使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘 。但是对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

本篇文章只讨论如何使用,暂不考虑其原理,之后有空会另外开篇文章说明。

如何使用

常见的触发硬件加速的css属性:

  • transform
  • opacity
  • filters
  • Will-change

效果

我们可以先看个例子。我通过使用chrome的Performance捕获了一段时间的回流重绘情况,实际结果如下图:

image-20181210225609533

从图中我们可以看出,在动画进行的时候,没有发生任何的回流重绘。如果感兴趣你也可以自己做下实验。

重点

  • 使用css3硬件加速,可以让transform、opacity、filters这些动画不会引起回流重绘
  • 对于动画的其它属性,比如background-color这些,还是会引起回流重绘的,不过它还是可以提升这些动画的性能。

css3硬件加速的坑

  • 如果你为太多元素使用css3硬件加速,会导致内存占用较大,会有性能问题。
  • 在GPU渲染字体会导致抗锯齿无效。这是因为GPU和CPU的算法不同。因此如果你不在动画结束的时候关闭硬件加速,会产生字体模糊。

总结

本文主要讲了浏览器的渲染过程、浏览器的优化机制以及如何减少甚至避免回流和重绘,希望可以帮助大家更好的理解回流重绘。

转载自 https://segmentfault.com/a/1190000017329980

0%