垃圾回收 & 运行机制
一.垃圾回收
1. 计算机组成
我们编写的软件首先读取到内存,用于提供给 CPU 进行运算处理。
内存的读取和释放,决定了程序性能。
冯·诺依曼结构:
源代码的本质就是字符串:
源代码就是普通的字符串,编译器就是通过解析器把字符串生成一个结构型的数据,这个结构型的数据我们把它称之为AST(抽象语法树)。
查看源代码的AST:转换AST地址
2. 解释与编译
众所周知,计算只能识别二进制,任何程序或软件,最终都要经过编译或解释转换成二进制才能被计算机识别。源代码,源代码就是由程序员使用各种编程语言编写的还未经编译或者解释的程序文本,编译或解释能把源代码翻译成等效的二进制代码,也就是CPU能够识别的机器语言。
编译和解释都是对源代码的解释处理方式,而由于他们的操作方法不同,所以会有不同的运行的效果:
- 编译是把源代码的每一条语句都编译成机器语言,并最终生成二进制文件,这样运行时计算机可以直接以机器语言来运行此程序,在运行时会有很好的性能;
- 解释器是只有在执行到对应的语句时才会将源代码一行一行的解释成机器语言,给计算机来执行。
编译相当于做好了一桌子菜,可以直接开吃了;而解释就相当于吃火锅,需要一边煮一边吃。
JavaScript 属于解释型语言,它需要在代码执行时,将代码编译为机器语言。
Compiler编译过程:词法分析 -> 语法分析 -> 语义分析 -> 代码生成;
ast(abstract struct tree)抽象语法树
3. JavaScript 引擎
JavaScript其实有众多引擎,只不过 v8 是我们最为熟知的。
- V8 (Google),用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js;
- JavaScriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari ,2008 年实现了编译器和字节码解释器,升级为了 SquirrelFish。苹果内部代号为“Nitro”的 JavaScript 引擎也是基于 JavaScriptCore 引擎的;
- Rhino,由 Mozilla 基金会管理,开放源代码,完全以 Java 编写,用于 HTMLUnit;
- SpiderMonkey (Mozilla),第一款 JavaScript 引擎,早期用于 Netscape Navigator,现时用于 Mozilla Firefox。
谷歌的 Chrome 使用 V8,Safari 使用 JavaScriptCore,Firefox 使用 SpiderMonkey。
简单看一下 V8 的处理过程:
- 始于从网络中获取 JavaScript 代码;
- V8 解析源代码并将其转化为抽象语法树(AST);
- 基于该 AST,Ignition 解释器可以开始做它的事情,并产生字节码;
- 在这一点上,引擎开始运行代码并收集类型反馈;
- 为了使它运行得更快,字节码可以和反馈数据一起被发送到优化编译器,优化编译器在此基础上做出某些假设,然后产生高度优化的机器代码;
- 如果在某些时候,其中一个假设被证明是不正确的,优化编译器就会取消优化,并回到解释器中。
traverse(ast, {
FunctionDeclaration: function(path) {
path.node.id.name = "x";
},
VariableDeclaration: function(path) {
// 匹配,map 映射
}
});
4. 垃圾回收
垃圾回收,又称为:GC(garbage collection)。
GC
即 Garbage Collection
,程序工作过程中会产生很多 垃圾
,这些垃圾是程序不用的内存或者是之前用过了,以后不会再用的内存空间,而 GC
就是负责回收垃圾的,因为他工作在引擎内部,所以对于我们前端来说,GC
过程是相对比较无感的,这一套引擎执行而对我们又相对无感的操作也就是常说的 垃圾回收机制
了。
当然也不是所有语言都有 GC
,一般的高级语言里面会自带 GC
,比如 Java、Python、JavaScript
等,也有无 GC
的语言,比如 C、C++
等,那这种就需要我们程序员手动管理内存了,相对比较麻烦。
上面提到了内容存储的方式,变量会在栈中存储,对象会在堆中存储,例如:
let a = {name: 'heyi'};
a = [1, 2, 3, 4, 5];
可达性:可达性是指从一个顶点到另一个顶点的容易程度。
在javascript中,当一个变量没法被访问,即不可达时,就会被回收。
4.1 引用计数法
多次累加,为 0 时进行清除;
它的策略是跟踪记录每个变量值被使用的次数。
- 当声明了一个变量并且将一个引用类型赋值给该变量的时候这个值的引用次数就为 1;
- 如果同一个值又被赋给另一个变量,那么引用数加 1;
- 如果该变量的值被其他的值覆盖了,则引用次数减 1;
- 当这个值的引用次数变为 0 的时候,说明没有变量在使用,这个值没法被访问了,回收空间,垃圾回收器会在运行的时候清理掉引用次数为 0 的值占用的内存;
看个例子:
function test(){
let A = new Object() +1 +1 = +2 -1 = 1
let B = new Object() +1 +1 = +2 -1 = 1
A.b = B
B.a = A
A = null
B = null
}
这个算法最怕的就是循环应用,还有比如 JavaScript 中不恰当的闭包写法。
优点
引用计数算法
的优点我们对比 标记清除来
看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾;而 标记清除算法
需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC
,另外,标记清除算法
需要遍历堆里的活动以及非活动对象来清除,而引用计数算法
则只需要在引用时计数就可以了。
缺点
引用计数
的缺点想必大家也都很明白了,首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的。
4.2 标记清除算法
标记清除(Mark-Sweep),目前在 JavaScript引擎
里这种算法是最常用的,到目前为止的大多数浏览器的 JavaScript引擎
都在采用标记清除算法,各大浏览器厂商还对此算法进行了优化加工,且不同浏览器的 JavaScript引擎
在运行垃圾回收的频率上有所差异。
此算法分为 标记
和 清除
两个阶段,标记阶段即为所有活动对象做上标记,清除阶段则把没有标记(也就是非活动对象)销毁。
当变量进入执行环境时,反转某一位(通过一个二进制字符来表示标记),又或者可以维护进入环境变量和离开环境变量这样两个列表,可以自由的把变量从一个列表转移到另一个列表。
引擎在执行 GC(使用标记清除算法)时,需要从出发点去遍历内存中所有的对象去打标记,而这个出发点有很多,我们称之为一组 根
对象,而所谓的根对象,其实在浏览器环境中包括又不止于 全局Window对象
、文档DOM树
。
整个标记清除算法大致过程就像下面这样:
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0;
- 然后从各个根对象开始遍历,把不是垃圾的节点改成1;
- 清理所有标记为0的垃圾,销毁并回收它们所占用的内存空间;
- 最后,把所有内存中对象标记修改为0,等待下一轮垃圾回收。
与引用计数算法不同的是,它不是多次累加,而是只有 0 和 1
0:没有任何地方引用; 1:有地方引用; 非0即1
优点
标记清除算法
的优点只有一个,那就是实现比较简单,打标记也无非打与不打两种情况,这使得一位二进制位(0和1)就可以为其标记,非常简单。
缺点
标记清除算法
有一个很大的缺点,就是在清除之后,剩余的对象内存位置是不变的,也会导致空闲内存空间是不连续的,出现了 内存碎片
,并且由于剩余空闲内存不是一整块,它是由不同大小内存组成的内存列表,这就牵扯出了内存分配的问题。
综上所述,标记清除算法或者说策略就有两个很明显的缺点:
- 内存碎片化,空闲内存块是不连续的,容易出现很多空闲内存块,还可能会出现分配所需内存过大的对象时找不到合适的块;
- 分配速度慢,因为即便是使用
First-fit
策略,其操作仍是一个O(n)
的操作,最坏情况是每次都要遍历到最后,同时因为碎片化,大对象的分配效率会更慢。
归根结底,标记清除算法的缺点在于清除之后剩余的对象位置不变而导致的空闲内存不连续,所以只要解决这一点,两个缺点都可以随之完美解决。
4.3 标记整理算法
标记整理(Mark-Compact)算法 就可以有效地解决,它的标记阶段和标记清除算法没有什么不同,只是标记结束后,标记整理算法会将活着的对象(即不需要清理的对象)向内存的一端移动,最后清理掉边界的内存。
5. 内存管理
V8 的垃圾回收策略主要基于分代式垃圾回收机制,V8 中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收器也就是不同的策略管理垃圾回收。
5.1 新生代
当新加入对象时,它们会被存储在使用区。然而,当使用区快要被写满时,垃圾清理操作就需要执行。在开始垃圾回收之前,新生代垃圾回收器会对使用区中的活动对象进行标记。标记完成后,活动对象将会被复制到空闲区并进行排序。然后,垃圾清理阶段开始,即将非活动对象占用的空间清理掉。最后,进行角色互换,将原来的使用区变成空闲区,将原来的空闲区变成使用区。
如果一个对象经过多次复制后依然存活,那么它将被认为是生命周期较长的对象,且会被移动到老生代中进行管理。除此之外,还有一种情况,如果复制一个对象到空闲区时,空闲区的空间占用超过了25%,那么这个对象会被直接晋升到老生代空间中。25%比例的设置是为了避免影响后续内存分配,因为当按照 Scavenge
算法回收完成后,空闲区将翻转成使用区,继续进行对象内存分配。
5.2 老生代
不同于新生代,老生代中存储的内容是相对使用频繁并且短时间无需清理回收的内容。这部分我们可以使用标记整理进行处理。
从一组根元素开始,递归遍历这组根元素,遍历过程中能到达的元素称为活动对象,没有到达的元素就可以判断为非活动对象
清除阶段老生代垃圾回收器会直接将非活动对象进行清除。
5.3 并行回收
为了减少主线程阻塞,我们在进行 GC 处理时,使用辅助进程。
5.3.1 全停顿标记
这个概念看字眼好像不好理解,其实如果用前端开发的术语来解释,就是阻塞。
虽然我们的 GC 操作被放到了主进程与子进程中去处理,但最终的结果还是主进程被较长时间占用。
5.3.2 切片标记
增量就是将一次 GC
标记的过程,分成了很多小步,每执行完一小步就让应用逻辑执行一会儿,这样交替多次后完成一轮 GC
标记(如下图)。
5.3.3 三色标记
三色:白灰黑
我们这里的灰,表示的是一个中间状态,为什么会有这个中间状态呢?
- 白色:未被标记的对象;
- 灰色:自身被标记,该对象的引用对象没有被标记;
- 黑色:该对象和成员对象 (该对象的引用对象)皆被标记。
标记过程:
- 在
GC
标记开始的时候,所有的对象均为白色; - 将所有的
GC Roots
直接引用的对象标记为灰色; - 判断灰色对象是否存在子引用,若存在子引用对象,则将其所有的子引用对象标记为灰色,当前对象标记为黑色;
- 按照此步骤 3 ,依此类推,直至没有灰色,本轮标记完成,并且把标记为白色的对象称为不可达对象,即垃圾对象。
- 清理白色对象,进行垃圾回收。
5.3.4 写屏障(增量中修改引用)
这一机制用于处理,在增量标记进行时修改引用的处理,可自行修改为灰色。
5.3.5 惰性清理
增量标记只是用于标记活动对象和非活动对象,真正的清理释放内存,V8 采用的则是惰性清理(Lazy Sweeping)方案。
在增量标记完成后,进行清理。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript
脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理,直到所有的非活动对象内存都清理完毕。
5.4 并发回收
还记得 react 中的 Concurrent 吗?
我们想想 React 演进过程,是不是就会觉得从并行到并发的演进变得很合理了呢?
并发回收其实是更进一步的切片,几乎完全不阻塞主进程。
5.5 总结
分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,而一些大、老、存活时间长的对象作为老生代,使其很少接受检查,新老生代的回收机制及频率是不同的,可以说此机制的出现很大程度提高了垃圾回收机制的效率。
5.6 面试常问
怎么理解内存泄漏?
怎么解决内存泄漏,代码层面如何优化?
- 减少查找;
- 减少变量声明;
- 使用 Performance + Memory 分析内存与性能;
- setTimeout、setInterval 需要及时清除;
- 事件绑定的清除等。
二. 运行机制
1. 前言
1.1 进程和线程
- 进程:进行中的程序,能够独立运行,并且有自己资源空间的任务,包括运行中的程序,程序所使用的资源;
- 线程:CPU调度最小的单位,一个进程中可以有多个线程;
Q:上下文切换,是进程更快还是线程更快?
A:线程。
1.2 JS单线程
JavaScript之所以是单线程的,主要出于以下几个方面原因:
- 简化并发问题;
- 避免浏览器环境的限制;
- 通过事件循环机制实现异步编程;
简化并发问题是JavaScript设计单线程的主要原因,这可以有效避免产生竞态条件和死锁等问题。
2. 浏览器
2.1 浏览器进程
一个浏览器包含哪些进程呢?
浏览器主进程
:一个浏览器只有一个主进程,负责页面的显示与交互,各个页面的管理,创建和销毁其他进程,网络的资源管理和下载;第三方插件进程
;GPU进程
:3D渲染;渲染进程
:称为浏览器渲染 或 浏览器内核,内部是多线程的,主要负责页面渲染,脚本执行,事件处理等;网络进程
;
浏览器主进程
- 协调控制其他子进程(创建、销毁);
- 浏览器界面显示,用户交互,前进、后退、收藏;
- 将渲染进程得到的内存中的Bitmap,绘制到用户界面上;
- 存储功能等。
第三方插件进程
- 每种类型的插件对应一个进程,仅当使用该插件时才创建。
GPU进程
- 用于3D绘制等;
渲染进程,就是我们说的 浏览器内核
- 排版引擎Blink和JavaScript引擎V8都是运行在该进程中,将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页;
- 负责页面渲染,脚本执行,事件处理等;
- 每个tab页一个渲染进程;
- 出于安全考虑,渲染进程都是运行在沙箱模式下。
网络进程
- 负责页面的网络资源加载,之前作为一个模块运行在浏览器主进程里面,最近才独立成为一个单独的进程;
面试题:浏览器从输入地址到页面渲染,整个执行过程是怎样的?
-
浏览器根据请求的URL交给DNS域名解析,找到真实IP,向服务器发起请求;
-
服务器交给后台处理完成后返回数据, 浏览器接收⽂件 ( HTML、JS、CSS 、图象等);
-
浏览器对加载到的资源 ( HTML、JS、CSS 等) 进行语法解析, 建立相应的内部数据结构
( 如 HTML 的 DOM ); -
载⼊解析到的资源⽂件 -> 渲染页面 -> 完成。
2.2 渲染进程
渲染进程包含些什么呢?
GUI渲染线程
:
- 负责渲染浏览器界面,解析html,css,构建dom树和render树,布局和绘制;
- 当重绘和回流的时候就会执行这个线程;
JS引擎线程
:
- 也称js内核,负责处理js脚本程序,例如v8引擎;
- 负责解析js脚本,运行代码;
- 等待任务队列中的任务,一个tab页只有一个js进程;
事件触发线程
:
- 归属于浏览器而不是js引擎,用了控制事件循环 ;
- 当js引擎执行 settimeout 类似的代码块时,会将对应任务添加到事件线程task queue中;
- 由于js单线程的关系,这些等待处理的事件都需要排队等待js引擎处理;
定时器触发线程
:
- settimeout / setinterval所在的线程;
- 通过单独线程来计时触发定时,计时完毕后,添加到事件队列task queue中,等待js引擎执行;
异步http request请求线程
:
- 在 XMLHttpRequest 连接后通过浏览器新开一个线程请求;
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列task queue中,等待js引擎执行;
3. event loop 事件循环
3.1 js引擎的事件循环机制
js执行时主要分为两类任务:同步 和 异步。
同步任务交给js引擎线程,异步事件交给其对应的线程去处理,处理完成后再添加到事件队列task queue中,等待js引擎去处理;
由此,形成了一个循环:
执行同步任务 -> 查看 task queue 中是否有任务 -> 执行 task queue 中的任务 -> 执行同步任务
我们可以看到:异步函数并不是马上执行的,而是先放到任务队列中;
任务队列(task queue)分为宏任务 和 微任务。
3.2 宏任务 与 微任务
3.2.1 宏任务
1.宏任务有哪些?
- 整体代码 script;
- setTimeout / setInterval / setImmediate;
- I/O操作(输入输出,比如读取文件操作、网络请求);
- ui render(dom渲染,即更改代码重新渲染dom的过程);
- 异步ajax等;
2.宏任务的执行顺序:
宏任务 -> GUI渲染 -> 宏任务
3.2.2 微任务
1.微任务有哪些?
- Promise(then、catch、finally);
- async/await;
- process.nextTick;
- Object.observe(⽤来实时监测js中对象的变化);
- MutationObserver(监听DOM树的变化);
注意:Promise.then 只有then
后面的代码才属于微任务
。
new Promise(function (resolve) {
// 宏任务代码
console.log('macro task');
resolve();
}).then(() -> {
// 微任务代码
console.log('micro task');
});
2.微任务的执行顺序:
宏任务 -> 当前产生的所有微任务 -> GUI渲染 -> 宏任务
3.3 完整的事件循环
事件循环的整体流程如下图所示:
- 执行一个
宏任务
; - 执行过程中如果遇到
微任务
,就将它添加到微任务
的任务队列中; 宏任务
执行完毕后,立即执行当前微任务队列
中的所有微任务
;- 当前
微任务队列
执行完毕,开始检查渲染,然后GUI线程
接管渲染; - 渲染完毕后,
JS线程
继续接管,开始下一个宏任务
。
先执行宏任务,还是微任务?
先执行 宏任务,再执行 微任务。
因为第一次执行的同步代码是归属到宏任务中的,然后再执行这些同步代码产生的微任务。
3.4 面试题
示例1:
console.log(1);
queueMicrotask(() => {console.log(2)});
Promise.resolve().then(() => console.log(3));
setTimeout(() => {console.log(4)})
问:上面的打印顺序是怎么样的???
首先,任务js 主进程的内容先执行,1 js 常规的代码, 2 微任务,3 微任务,4 宏任务:
- 执行同步代码;
- 执行一个宏任务(执行栈中没有就从任务队列中获取)。
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中。
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)。
- 当前宏任务执行完毕,开始检查渲染,然后渲染线程接管进行渲染。
- 渲染完毕后,JavaScript 线程继续接管,开始下一个循环。
宏任务->微任务->渲染
所以上面代码的执行顺序是:1 2 3 4。
示例2:
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
Promise.resolve().then(() => setTimeout(() => console.log(4)));
Promise.resolve().then(() => console.log(5));
setTimeout(() => console.log(6));
console.log(7);
先执行同步代码:1 7
微任务队列:3 setTimeout(() => console.log(4) 5
0
宏任务队列:2 6 4
所以上面代码的执行顺序是:1 7 3 5 2 6 4。
示例3:
Promise.resolve().then(() => {
// 微任务1
console.log('Promise1')
setTimeout(() => {
// 宏任务2
console.log('setTimeout2')
}, 0)
})
setTimeout(() => {
// 宏任务1
console.log('setTimeout1')
Promise.resolve().then(() => {
// 微任务2
console.log('Promise2')
})
}, 0)
// p1 s1 p2 s2
示例4:
console.log('stack [1]');
setTimeout(() => console.log("macro [2]"), 0);
setTimeout(() => console.log("macro [3]"), 1);
const p = Promise.resolve();
for(let i = 0; i < 3; i++) p.then(() => {
setTimeout(() => {
console.log('stack [4]')
setTimeout(() => console.log("macro [5]"), 0);
p.then(() => console.log('micro [6]'));
}, 0);
console.log("stack [7]");
});
console.log("macro [8]");
// 请说出答案
/* Result:
stack [1]
macro [8]
stack [7], stack [7], stack [7]
macro [2]
macro [3]
stack [4]
micro [6]
stack [4]
micro [6]
stack [4]
micro [6]
macro [5], macro [5], macro [5]
--------------------
but in node in versions < 11 (older versions) you will get something different
stack [1]
macro [8]
stack [7], stack [7], stack [7]
macro [2]
macro [3]
stack [4], stack [4], stack [4]
micro [6], micro [6], micro [6]
macro [5], macro [5], macro [5]
more info: https://blog.insiderattack.net/new-changes-to-timers-and-microtasks-from-node-v11-0-0-and-above-68d112743eb3
*/
同步代码:stack [1] ,macro [8];
微任务队列:for循环stack [7],setTimeout(() => {
console.log(‘stack [4]’)
setTimeout(() => console.log(“macro [5]”), 0);
p.then(() => console.log(‘micro [6]’));
}, 0);
宏任务队列:macro [2],macro [3];
先执行同步代码18,再执行微任务队列7,微任务队列执行到setTimeout时把它放到宏任务队列中,此时宏任务队列为:
宏任务队列:macro [2],macro [3],console.log(‘stack [4]’)
setTimeout(() => console.log(“macro [5]”), 0);
p.then(() => console.log(‘micro [6]’);
接着继续执行宏任务队列:2和3,执行到后面的函数时,先执行同步代码4,再执行微任务6,最后是宏任务5由于还有for循环,所以是444->666->555的顺序。
示例5:
function test() {
console.log(1);
setTimeout(function () {
// timer1
console.log(2);
}, 1000);
}
test();
// setTimeout 没写时间,等价于0
setTimeout(function () {
// timer2
console.log(3);
});
// 对于 Promise,then前面算是宏任务的同步代码,then后面时微任务
new Promise(function (resolve) {
console.log(4);
setTimeout(function () {
// timer3
console.log(5);
}, 100);
resolve();
}).then(function () {
setTimeout(function () {
// timer4
console.log(6);
}, 0);
console.log(7);
});
console.log(8);
// 1 4 8 7 3 6 5 2
// 同步: 1 4 8
// 微任务: 7
// 任务队列: 3 6 5 2
面试真题1:
async function async1() {
console.log("async1 start");
await async2();
console.log("async1 end");
}
async function async2() {
console.log("async2");
}
console.log("script start");
setTimeout(function() {
console.log("setTimeout");
}, 0);
async1();
new Promise(function(resolve) {
console.log("promise1");
resolve();
}).then(function() {
console.log("promise2");
});
console.log('script end')
面试真题2:
const $inner = document.querySelector('#inner')
const $outer = document.querySelector('#outer')
function handler () {
console.log('click') // 直接输出
Promise.resolve().then(_ => console.log('promise')) // 注册微任务
setTimeout(() => console.log('timeout')) // 注册宏任务
requestAnimationFrame(_ => console.log('animationFrame')) // 注册宏任务
$outer.setAttribute('data-random', Math.random()) // DOM属性修改,触发微任务
}
new MutationObserver(_ => {
console.log('observer')
}).observe($outer, {
attributes: true
})
$inner.addEventListener('click', handler)
$outer.addEventListener('click', handler)
以上执行的顺序:click
-> promise
-> observer
-> click
-> promise
-> observer
-> animationFrame
-> animationFrame
-> timeout
-> timeout
4. Node 事件循环机制
5. 伪代码实现任务执行
while (true){
// 1. Get one macrotask (oldest) task item
task = macroTaskQueue.pop();
execute(task);
// 2. Go and execute microtasks while they have items in their queue (including those which were added during this iteration)while (microtaskQueue.hasTasks()){
const microTask = microtaskQueue.pop();
execute(microTask);
}
// 3. If 16ms have elapsed since last time this condition was trueif (isPaintTime()){
// 4. Go and execute animationTasks while they have items in their queue (not including those which were added during this iteration) const animationTasks = animationQueue.getTasks();
for (task in animationTasks){
execute(task);
}
repaint(); // render the page changes (via the render pipeline)
}
}
宏任务->微任务->执行环境->重绘
原文链接:https://juejin.cn/post/7321596167584841764 作者:Nunumaymax