前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >javascript事件循环

javascript事件循环

作者头像
腾讯IVWEB团队
发布2020-06-29 12:02:46
1.2K0
发布2020-06-29 12:02:46
举报

JavaScript事件循环

JavaScript单线程

JavaScript 从一开始被创造出来就使用的单线程,这主要与他的用途相关。JavaScript主要用来与用户交互、操作网页上的dom元素等工作。

如果JavaScript是多线程程序,那么就需要开发者考虑很多并发的问题,如多个线程对同一个 dom 进行修改以后,那浏览器会采取哪一个呢,这个无法确定,当然可以提供锁的机制来解决这个问题,那将会提高JavaScript的复杂性。

标题中说的JavaScript单线程并不是说程序运行真的只是依赖一条线程,他实际有多条协助线程,只有一条主线程来调度协助线程,协助线程会用来做一些耗时任务,这样做是为了防止耗时任务阻碍了网页响应用户的操作,提升网页性能等。这些线程功能不一,都有着自己独有的任务,下面将简单介绍下这些协助线程有哪些(介绍的都是浏览器渲染进程中的线程):

  • GUI线程:将页面从文档处理成位图,处理页面渲染、重绘、回流等任务
  • JavaScript引擎线程:JavaScript同步任务、回调任务执行的场所,JavaScript程序调度中心
  • 事件触发线程:存放任务队列的场所,异步任务完成以后触发的事件都会存放到这个线程中,这个线程中存在多个任务队列。
  • 定时器线程:用来给定时任务定时
  • 异步http线程:页面ajax等网络请求任务处理等待响应的线程

浏览器event loop遵循HTML5标准,node环境下的event loop是通过libuv实现,两个环境下的JavaScript事件循环机制几乎不是同一回事,因此下文将浏览器和node环境下的事件循环分开介绍。

浏览器环境

什么是event loop

在讲 event loop 之前,我们需要知道程序执行多任务的方式有哪些(以下内容来自阮一峰博客):

  • 排队处理:进程每次只能执行一个任务,只有当上一个任务执行完成以后才能够进行下一项任务的处理。
  • 创建新的进程:为每一个任务新建一个进程。
  • 创建新的线程:因为进程太浪费资源,现在的程序允许在一个进程中包含多个线程,然后线程执行这些任务。

JavaScript 采用第一种方式执行任务的程序,第一种任务执行方式会有如下两个问题:

  1. JavaScrip执行线程处理大量任务或者耗时任务时,执行线程一直处于占用状态,用户对页面进行操作以后,无法立即响应用户,直到前面的任务都执行完以后,才能处理后面的任务。
  2. JavaScript单线程无法很好的利用现代多核CPU计算机,因此在HTML5中提出了 web worker标准,允许JavaScript创建多个线程来处理任务。但是子线程完全受主线程控制,并且子线程无法操作DOM。
JavaScript永不阻塞

JavaScript中同步任务都需要在主线程执行栈中运行,只有当前面任务执行完成以后才能处理运行后面的同步任务。

主线程运行时,会产生堆和栈,执行栈中运行的时候会去调用一些API,如果调用的是异步函数API,如处理I/O(ajax请求)、定时器、DOM事件监听等,执行栈就会将这些异步任务挂到对应的线程中,然后执行栈再运行其他同步任务。这些被分配到其他线程中的任务只有当事件触发的时候,异步线程就会将带有回调函数的事件放入到事件触发线程中的事件队列里面去。

被放到事件队列里面的任务不会立即执行,需要等待主线程主动读取这些事件,然后在执行栈中执行这些任务的回调函数。

当JavaScript执行栈处于空闲的状态时,主线程就会主动去查看事件队列是否存在未处理的事件。

  • 如果存在,主线程就会读取队列中第一个事件,并将这个事件对应的回调函数放入到执行栈中,然后执行里面的同步代码,执行完后就又去判断事件队列是否为空,如此往复。
  • 如果不存在,主线程也会不停的去判断事件队列中是否有待处理的事件

前面说到的主线程往复的判断读取事件队列的过程就是 event loop(如下图展示过程)

(图片来自https://vimeo.com/96425312)

任务

前面只是讲述了浏览器JavaScript event loop过程,以及提及到有一个事件队列来存放这些触发的事件。下面将介绍事件触发线程中存在哪些任务队列,以及这些事件队列的优先级。

任务分类

事件触发线程中存在多个任务队列,异步线程中触发的事件都会将事件存放到这些任务队列中。

这些任务可以分为两类,microtask(微任务)、macrotask(宏任务),在事件触发线程中微任务队列只能有一个,而宏任务队列就可以有多个,实际我们平时开发时用到的宏任务队列也只用到了一个。

JavaScript中异步API分类:

  • 宏任务:script(整体代码)、setTimeout、setInterval、I/O
  • 微任务:Promises、MutationObserver
执行顺序

对于微宏任务,主线程调用这些任务也是有一定的顺序,下面将介绍一下微任务和宏任务的调用顺序:

  1. 主线程读取一个宏任务执行,执行完毕后,执行下一步。(程序开始的时候只有 script 中的代码,因此只能运行 script 中的代码)
  2. 当执行栈处于空闲状态时,主线程判断微任务队列是否为空,不为空就读取微任务队列中的第一个任务,放到执行栈中执行。执行完以后,再判断微任务队列是否为空,如果有,再读取再执行,如此往复,直到微任务队列为空。
  3. 更新渲染(浏览器在一段时间内会将更新任务存放到渲染队列中去,直到时间到了,或者存储的量到达某个点的时候,就会释放,渲染页面。这样做是为了减少页面重排和重绘。这里规范允许浏览器自己选择更新时机,因此实际上可能不会在每一轮事件循环都去更新渲染)

event loop会循环执行上面3步。

栗子

下面例子中我们会使用performance工具来监控执行顺序,橘色代表js运行,紫色代表页面布局计算,绿色代表绘制任务。

栗子1: 5.js

代码语言:javascript
复制
setTimeout(function() {
    console.log('setTimeout 1');
    Promise.resolve().then(function() {
        console.log('promise 1');
    }).then(function() {
        console.log('promise 2');
    })
});
setTimeout(function() {
    console.log('setTimeout 2');
    Promise.resolve().then(function() {
        console.log('promise 3');
    }).then(function() {
        console.log('promise 4');
    })
});

// 执行结果
setTimeout 1
promise 1
promise 2
setTimeout 2
promise 3
promise 4

流程:

  • 执行script代码,调用setTimeout 计时器API,将定时任务交给定时器线程来做。后面不停的检测事件触发线程中的任务队列是否为空
  • 定时器没有设置时间,浏览器给出默认定时时间((HTML5标准规定setTimeout最小计时4ms,但是各个浏览器实现并没有按照规定来,谷歌是0-1这两个值不太清楚是哪一个)),定时完成,将对应的事件压入宏任务队列中
  • 主线程检测到宏任务队列不为空,读取队列中的第一个任务,将任务的对调函数放到执行栈中执行,调用Promise.resolve()函数,将then定义的回调函数压入微任务队列中,此时宏任务队列还有个任务等待执行
  • 主线程执行宏任务后,检测微任务队列是否为空,不为空依次读取微任务队列中的任务,直到微任务队列为空
  • 检测宏任务队列是否为空,不为空,继续上面第三步。。。

栗子2: 1.js

代码语言:javascript
复制
setTimeout(() => {
    text.textContent = 2;
});

text.textContent = 1;

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

// 这里实际运行出来有两种结果
结果一:
页面只渲染一次:2

结果二:
页面会渲染两次分别是1,2

这里简述下结果二的流程:

  • 主线程从宏任务队列中读取点击事件,将事件中设置的回调函数放到执行栈中执行,开始解析执行setTimeout异步API,将计时任务放到计时器线程中运行计时;修改dom节点内容,根据上面说的event loop UI rendering将会放到本轮循环最后再执行;执行Promise.resolve()直接将后面then里面的回调函数放入微任务队列中。由于setTimeout没有设置定时时间,此时计时到了,就会将触发计时完成事件并存放到宏任务队列中
  • 检测微任务队列是否为空,不为空,读取里面的任务并执行
  • 页面更新渲染

结果一流程:

  • 在第一轮loop中跳过了UI rendering阶段,直到第二轮loop最后才执行UI rendering

很显然结果一与上面说的event loop过程不一致,实际上浏览器在实现为了防止大量重排和重绘,在更新渲染过程做了优化,让 UI rendering 并不是在每轮循环中都运行,UI rendering 执行时机具有不确定性,GUI线程中实际也存放了一个更新队列,当存放到一定时间、存放的数量到达临界值就会释放队列,还有一个情况也会迫使GUI线程去更新页面,那就是使用js去获取dom元素样式的时候,浏览器为了给出一个准确的值,只能将更新队列中的任务。UI rendering调用时机取决于浏览器以及程序执行时cpu、gpu的状态决定。

栗子3: 4.js

代码语言:javascript
复制
setTimeout(() => {
    text.textContent = 2;
})

text.textContent = 1;

for (let i = 0; i < 1000000; i++) {
    Promise.resolve().then(() => {})
}

我们知道执行一次宏任务后,就会检测微任务队列,并且只有当微任务队列为空的时候才能够执行其他的任务,因此大量微任务将阻塞整个应用程序的运行,上面例子中,只有当所有的Promise回调执行完以后才会更新渲染和执行宏任务(GUI线程和主线程是互斥的,当JavaScript主线程代码执行的时候,GUI线程会被挂起),浏览器可能对微任务数量做了限制,但是实际操作中没有测试出来(微任务多的时候页面几乎卡死了)。

node环境

六个阶段

这里不在讲述event loop概念了,概念和前面差不多,就是不断从任务队列中取任务然后执行。node 中将每一次轮循分成6个阶段,就是下面展示的六个阶段,每走完一次循环就是一个tick,并且还要注意的是node的事件循环运行在主线程。

代码语言:javascript
复制
    ┌───────────────────────┐
┌>│                  timers                   │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │            I/O callbacks                 │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │             idle, prepare                │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │                  poll                        │<──┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │                  check                     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└─┤    close callbacks                   │
    └───────────────────────┘

​ (从node官网偷的图形)

  • timers阶段:执行setTimeout和setInterval中定时完成的回调函数,其中定时器有可能因为系统调度的问题或者由于其他回调导致不准确情况
  • I/O callbacks阶段:上一轮循环部分I/O callback会被延迟到这一轮的这个阶段执行。主要执行一些系统操作错误回调,如stream、tcp、udp通信错误等
  • idle,prepare阶段:node内部使用
  • poll阶段:除了timer、close、check以外的任务,都会将回调函数放入到这个阶段中的任务队列中,一定条件下,node会阻塞在这里
  • check阶段:执行setImmediate设置的callback
  • close callbacks 阶段:套接字或处理函数关闭,通过 close 定义的回调函数就会在这个阶段执行,如执行 socket.destroy 后,socket.on('close', callback)定义的callback 就会存放在本阶段的任务队列中。

每一个阶段都有一个用来存放回调函数的任务队列。

node离开某个阶段需要满足后面的条件,node将任务队列中的回调执行完毕或者任务队列中的回调数超过最高限制之后,就会离开这个阶段。

前面还没有提到 process.nextTick和promise的,这两个后面单独谈。

下面重点谈一下timers阶段、poll阶段、check阶段这三个阶段的流程。

timers阶段

setTimeout和setInterval定义的超过定时的回调函数将会存放到这个阶段中的任务队列中,当运行到这个阶段的时候,就会依次将回调函数取出来并执行,node中的记时器定时任务定时最小是 1,最大是多少记不到了。

poll阶段

poll阶段执行的是I/O回调函数,当异步I/O任务执行完成的时候,就会将他们的回调函数压入到任务队列中,node处于这个阶段的时候就会将该阶段存放的任务队列中的回调函数执行完。

poll阶段有以下两个重要的功能:

  1. 处理本阶段任务队列中的回调:执行完任务队列中的任务或者执行的任务数到达系统上限时就会离开该阶段
  2. 当poll queue为空的时候,检测timers中的任务队列是否为空
    • timers中的队列为空
      • 检测check阶段任务队列是否为空
        • 如果不为空,就会结束poll阶段,进入到check阶段,并执行check阶段中的任务队列;
        • 如果为空,事件循环就会阻塞在这个阶段。等待后面的callback加入到这个阶段的任务队列中,然后运行;检测timers阶段是否有任务待执行;检测check阶段是否有任务待执行
    • timers中任务队列不为空,event loop就会按照前面列出来的那六个阶段顺序循环进入到timers阶段,并执行该阶段中的任务队列

栗子1:1.js

代码语言:javascript
复制
const fs = require('fs');

const readerFile = callback => {
    fs.readFile('./file.txt', callback);
};

const startTime = Date.now();
let readEndTime = 0;
setTimeout(() => {
    const delay = Date.now() - startTime;
    console.log('延迟执行计时器callback:', delay);
    console.log('读取文件耗时:', readEndTime - startTime);
}, 10)

readerFile(() => {
    readEndTime = Date.now();
    
    while (Date.now() - readEndTime < 20) {} 
});

// 执行结果
延迟执行计时器callback: 23
读取文件耗时: 3

整个流程如下:

  • 启动程序,运行其他同步代码,初始化event loop
  • timers阶段,检测到任务队列为空,定时器设置的是 10ms,此时计时还没有超过这个10ms。
  • I/O callback 回调,没有任务
  • idle,prepare 忽略
  • poll阶段,任务队列为空,timers queue 为空,check queue 为空,此时阻塞在 poll 阶段。3ms或者4ms之后文件读取完成,将定义的callback被压入poll queue,重队列中取出并执行回调函数,执行这个回调函数花费20ms(定时器会在执行这个回调函数的时候完成,然后将回调压入timers queue中)。callback执行完成以后,poll queue 空闲,检测timers queue是否为空,timers queue 队列不为空,因此,退出poll阶段,继续走到后面阶段,直到回到timers阶段,途径 check阶段、close callback阶段,到达timers阶段,执行timers queue中的回调,这里虽然定时器设置的10ms就执行回调,但是实际被延迟到23ms后才被执行。

check阶段

这个阶段执行都是setImmediate定义的回调,当这个阶段中的任务队列不为空的时候,会让 event loop 暂时不阻塞在 poll 阶段。

setTimeout 和 setImmediate 区别

栗子2: 2.js
代码语言:javascript
复制
setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('immediate');
});

// 运行结果会有两种:
timeout
immediate
或者
immediate
timeout

node环境:setTimeout(f, 0) === setTimeout(f, 1)。

上面代码有两种结果的原因如下:在进入依次 loop前需要做一些耗时任务,每次tick都是先检测timers queue是否为空,是哪种结果完全由进入loop前的准备工作耗时是否超过1ms决定(定时器任务初始化也需要一定的时间,实际需要判断loop准备耗时是否超过1ms多)

  • 进入loop前准备耗时超过1ms,此时定时器计时任务完成,回调函数已经压入timers queue。event loop初始化成功,进入timers阶段,检测到timers queue不为空,执行里面的回调;后面到poll阶段,检测到poll queue为空,检测timers queue是否为空,为空,检测check queue是否为空,不为空,进入到check阶段执行setImmediate设置的回调,因此结果是timerout immediate
  • 进入loop前准备耗时小于1ms,此时定时器计时还没有完成。event loop初始成功,进入timers阶段,检测到timers queue为空,程序直接进入到下一个阶段,直到到达poll阶段,poll queue为空,检测timers queue是否为空,不为空,向后执行,进入check阶段,执行 setImmediate 设置的回调,回到timers阶段,执行timers queue中的回调,因此结果是immediate timerout
栗子3: 3.js
代码语言:javascript
复制
const fs = require('fs');

fs.readFile('./file.txt', () => {
    setTimeout(() => {
        console.log('timeout');
    });
    setImmediate(() => {
        console.log('immediate');
    });
});

// 结果只有一个
immediate
timerout

第一个tick,loop会阻塞在 poll 阶段,直到文件读取完成,将回调函数压入poll queue。检测到poll queue不为空,取出并执行回调函数,回调函数中执行 setTimeout生成一个定时任务(计时为1ms),执行setImmediate,向check queue压入回调函数。poll阶段的queue为空后,检测timers queue是否为空,检测check queue是否为空(实际上node中不管是timers还是check中的任务队列不为空的时候,都会经过这两个阶段,然后再阻塞在poll阶段)。执行poll阶段回调后的具体流程如下(这里假设定时任务已完成):

  • timers queu不为空,进入check阶段,执行check queue中的回调函数。
  • 进入下一个loop,进入timers阶段,执行任务队列中的回调函数

setTimeout和setImmediate这两个异步函数函数放到一个I/O回调函数中的时候,setImmediate回调始终优先调用,是由六个阶段的执行顺序决定的。

process.nextTick

process.nextTick不属于上面提到的任何阶段,每个阶段结束的时候都会执行完nextTick任务队列中的回调,并且在进入新的一轮loop的时候就会有一次机会去清空nextTick的回调。

栗子4: 4.js
代码语言:javascript
复制
setTimeout(() => {
    console.log('timeout')

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

// 执行结果(由于loop准备和nextTick 1执行耗费时间,才导致timeout在immediate之前被打印出来)
nextTick 1
timeout
nextTick 2
immediate
  • 进入 loop 前清空nextTick任务队列中的回调,打印出nextTick 1
  • 进入timers阶段,发现任务队列不为空,取出并执行timer queue中的回调,打印出 timeout,回调函数中调用了process.nextTick()将回调压入nextTick任务队列中
  • 离开timers阶段,清空nextTick任务队列的回调,打印出nextTick 2
  • 进入I/O callback,进入。。。
  • 进入poll阶段,检测到check queue不为空
  • 进入ckeck阶段,执行任务队列中的回调函数,打印出 immediate
microtask

nextTick中的任务队列执行完以后,还有其他的工作,如执行microtask,如promise回调。

栗子5: 5.js

代码语言:javascript
复制
setTimeout(() => {
    console.log('timeout')
    process.nextTick(() => {
        console.log('nextTick 2')

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

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

// 执行结果如下
nextTick 1
promise 2
timeout
nextTick 2
promise 1
setImmediate

栗子6: 6.js

前面讲浏览器事件循环的一个例子:

代码语言:javascript
复制
setTimeout(function() {
    console.log('setTimeout1');
    Promise.resolve().then(function() {
        console.log('promise1');
    }).then(function() {
        console.log('promise2');
    })
});
setTimeout(function() {
    console.log('setTimeout2');
    Promise.resolve().then(function() {
        console.log('promise3');
    }).then(function() {
        console.log('promise4');
    })
});

// 执行结果有两种情况,大多数情况是结果一
setTimeout1
setTimeout2
promise1
promise3
promise2
promise4
或者
setTimeout1
promise1
promise2
setTimeout2
promise3
promise4

结果一的流程:

  • 进入loop前检测是否有nextTick、microtask任务,有就执行,没有就进入loop
  • 检测timers queue中是否有回调任务,由于两个定时任务都是1ms后将回调写入任务队列中,检测到任务队列不为空(也有可能到达poll阶段后检测到timers queue不为空,然后回到timers阶段),执行完里面的回调函数,回调函数中调用Promise,将回调函数压入microtask队列中
  • 离开timers阶段,检测nextTick任务队列是否为空,为空,检测microtask队列是否为空,不为空,执行microtask任务队列中的回调函数,执行以后又触发一个microtask,将这个回调压入microtask队列中,继续检测队列是否为空,不为空,取出并执行回调,为空,则进入下个阶段

结果二流程(由于系统调度导致记时器定时器出现不准确的问题,进入loop时,可能一个定时器定时完成,而另一个没有完成定时):

  • 进入loop前检测是否有nextTick、microtask任务,有就执行,没有就进入loop
  • 进入timers阶段,检测到任务队列不为空,执行里面的回调,向microtask队列添加回调,timers queue为空
  • 离开timers阶段(后面这段时间另一个计时任务也定时结束),检测nextTick任务队列是否为空、检测microtask队列是否为空,不为空,就将队列中的回调执行完
  • 进入 I/O callback,进入idle, prepare
  • 进入poll阶段,检测到timers queue不为空,循环进入timers阶段,执行队列中的回调,后续操作和前面一样

浏览器执行出来的结果是结果二,流程如下:

  • 执行script同步代码,将定时任务挂到定时线程中,进行定时,定时线程中的两个定时任务时间到了,触发对应的事件,将两个回调函数放到macrotask队列中
  • JavaScript执行栈处于空闲状态,主线程查看microtask队列是否为空,为空。检测macrotask队列是否为空,不为空,取出队列中的第一个回调任务放到执行栈中执行,执行代码的时候,执行到Promise.resolve(),将then定义的回调函数放入microtask队列中
  • 第一个定时器回调(macrotask)执行完以后,检测microtask队列是否为空,不为空,主线程将任务对应的回调读取主线程执行,执行的时候又会生成一个microtask,然后放入到microtask队列中去
  • 继续检测microtask queue是否为空,不为空,继续取出来执行,如此往复,直到microtask队列为空
  • 主线程取出macrotask队列中的其他任务的回调函数放到执行栈中执行,后面过程和前面一样了。

setImmediate和process.nextTick

check阶段执行回调,如果回调中使用了setImmediate定义新的异步任务,那么新的回调将会在下一轮loop的check阶段执行。

栗子7: 7.js

代码语言:javascript
复制
setTimeout(() => {
    console.log('timeout')
}, 2);
setImmediate(() => {
    console.log('immediate 1')
    
    setImmediate(() => {
        console.log('immediate 2')
    })
})

// 运行结果
immediate 1
timeout
immediate 2

这样设计的目的是替代process.nextTick用来实现递归调用,我们知道nextTick任务队列会在每个阶段结束后清空。但是,如果遇到递归调用的时候,就会因为不断向nextTick任务队列中写入回调,导致整个程序阻塞,而无法运行其他更重要的任务,例子如下:

代码语言:javascript
复制
const fs = require('fs');
const startTime = Date.now();
let index = 0;

fs.readFile('./file.txt', () => {
    console.log('文件读取回调时间', Date.now() - startTime);
});

function nextTick () {
    if (index > 100000) return;
    index++;
    process.nextTick(nextTick);
}

nextTick();

// 运行结果
文件读取回调时间 26
代码语言:javascript
复制
const fs = require('fs');
const startTime = Date.now();
let index = 0;

fs.readFile('./file.txt', () => {
    console.log('文件读取回调时间', Date.now() - startTime);
});

function immediate() {
    if (index > 100000) return;
    index++;
    setImmediate(immediate);
}

immediate();
// 运行结果
文件读取回调时间 3

参考文章

javascript

前端发展史

栗子来源

从HTML5与PromiseA+规范看事件循环

JavaScript 异步、栈、事件循环、任务队列

Node.js Event Loop 的理解 Timers,process.nextTick()

不要混淆nodejs和浏览器中的event loop

Node.js 事件循环,定时器和 process.nextTick()https://www.cnblogs.com/kidney/p/6079530.html)

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • JavaScript事件循环
    • JavaScript单线程
      • 浏览器环境
        • 什么是event loop
        • 任务
        • 栗子
      • node环境
        • 六个阶段
        • timers阶段
        • poll阶段
        • check阶段
        • setTimeout 和 setImmediate 区别
        • process.nextTick
        • 栗子6: 6.js
        • setImmediate和process.nextTick
        • 参考文章
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档