浏览器工作原理--浏览器中的页面循环系统

一、概述

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

二、消息队列

(1)概念

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

(2)任务类型

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

2、包含了很多与页面相关的事件,如 JavaScript 执行、解析 DOM、样式计算、布局计算、CSS 动画等

(3)通常我们把消息队列中的任务称为宏任务,每个宏任务中都包含了一个微任务队列。(比喻:宏任务是开会分配的工作内容,微任务是工作过程中被临时安排的内容)

(4)微任务是在宏任务快要执行结束之前执行的。

三、事件循环流程

每个渲染进程都有一个消息队列,页面主线程按照顺序来执行消息队列中的事件,如执行 JavaScript 事件、解析 DOM 事件、计算布局事件、用户输入事件等等,如果页面有新的事件产生,那新的事件将会追加到事件队列的尾部。

(1)渲染进程专门有一个 IO 线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给渲染主线程。

(2)当页面主线程执行完成之后,又该如何保证页面主线程能够安全退出呢?

Chrome 是这样解决的,确定要退出当前页面时,页面主线程会设置一个退出标志的变量,在每次执行完一个任务时,判断是否有设置退出标志。

(3)当循环系统在执行一个任务的时候,都要为这个任务维护一个系统调用栈。这个系统调用栈类似于 JavaScript 的调用栈,只不过系统调用栈是 Chromium 的开发语言 C++ 来维护的 

四、setTimeout


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; 
  }
}

(1) 延迟队列

在 Chrome 中除了正常使用的消息队列之外,还有另外一个消息队列,这个队列中维护了需要延迟执行的任务列表,包括了定时器和 Chromium 内部一些需要延迟执行的任务。所以当通过 JavaScript 创建一个定时器时,渲染进程会将该定时器的回调任务添加到延迟队列中。

(2)当通过 JavaScript 调用 setTimeout 设置回调函数的时候,渲染进程将会创建一个回调任务,包含了回调函数 showName、当前发起时间、延迟执行时间。

(3)如何触发?

处理完消息队列中的一个任务之后,就开始执行 ProcessDelayTask 函数。ProcessDelayTask 函数会根据发起时间和延迟时间计算出到期的任务,然后依次执行这些到期的任务。等到期的任务执行完成之后,再继续下一个循环过程通过这样的方式,一个完整的定时器就实现了。

(3)取消定时器

1、设置一个定时器,JavaScript 引擎会返回一个定时器的 ID。那通常情况下,当一个定时器的任务还没有被执行的时候,也是可以取消的,具体方法是调用 clearTimeout 函数,并传入需要取消的定时器的 ID

2、浏览器内部实现取消定时器的操作也是非常简单的,就是直接从 delayed_incoming_queue 延迟队列中,通过 ID 查找到对应的任务,然后再将其从队列中删除掉就可以了。

(4)注意事项

1、如果当前任务执行时间过久,会影响定时器任务的执行

2、如果 setTimeout 存在嵌套调用,那么系统会设置最短时间间隔为 4 毫秒

因为在 Chrome 中,定时器被嵌套调用 5 次以上,系统会判断该函数方法被阻塞了,如果定时器的调用时间间隔小于 4 毫秒,那么浏览器会将每次调用的时间间隔设置为 4 毫秒

3、未激活的页面,setTimeout 执行最小间隔是 1000 毫秒

未被激活的页面中定时器最小值大于 1000 毫秒,也就是说,如果标签不是当前的激活标签,那么定时器最小的时间间隔是 1000 毫秒,目的是为了优化后台页面的加载损耗以及降低耗电量。

4、延时执行时间有最大值

Chrome、Safari、Firefox 都是以 32 个 bit 来存储延时值的,32bit 最大只能存放的数字是 2147483647 毫秒,这就意味着,如果 setTimeout 设置的延迟值大于 2147483647 毫秒(大约 24.8 天)时就会溢出,那么相当于延时值被设置为 0 了,这导致定时器会被立即执行。

5、使用 setTimeout 设置的回调函数中的 this 不符合直觉

五、XMLHttpRequest


 function GetWebData(URL){
    /**
     * 1:新建XMLHttpRequest请求对象
     */
    let xhr = new XMLHttpRequest()

    /**
     * 2:注册相关事件回调处理函数 
     */
    xhr.onreadystatechange = function () {
        switch(xhr.readyState){
          case 0: //请求未初始化
            console.log("请求未初始化")
            break;
          case 1://OPENED
            console.log("OPENED")
            break;
          case 2://HEADERS_RECEIVED
            console.log("HEADERS_RECEIVED")
            break;
          case 3://LOADING  
            console.log("LOADING")
            break;
          case 4://DONE
            if(this.status == 200||this.status == 304){
                console.log(this.responseText);
                }
            console.log("DONE")
            break;
        }
    }

    xhr.ontimeout = function(e) { console.log('ontimeout') }
    xhr.onerror = function(e) { console.log('onerror') }

    /**
     * 3:打开请求
     */
    xhr.open('Get', URL, true);//创建一个Get请求,采用异步


    /**
     * 4:配置参数
     */
    xhr.timeout = 3000 //设置xhr请求的超时时间
    xhr.responseType = "text" //设置响应返回的数据格式
    xhr.setRequestHeader("X_TEST","time.geekbang")

    /**
     * 5:发送请求
     */
    xhr.send();
}

(1) 回调函数

将一个函数作为参数传递给另外一个函数,那作为参数的这个函数就是回调函数。

(2)同步回调

 1、回调函数 callback 是在主函数返回之前执行的,我们把这个回调过程称为同步回调。

2、 每个任务在执行过程中都有自己的调用栈,那么同步回调就是在当前主函数的上下文中执行回调函数。

let callback = function() {
  console.log('i am do homework');
};
function doWork(cb) {
  console.log('start do work');
  cb();
  console.log('end do work');
}
doWork(callback);

(3)异步回调

1、回调函数在主函数外部执行的过程称为异步回调。

2、一般有两种方式

  • 第一种是把异步函数做成一个任务,添加到信息队列尾部;
  • 第二种是把异步函数添加到微任务队列中,这样就可以在当前任务的末尾处执行微任务了。
let callback = function() {
  console.log('i am do homework');
};
function doWork(cb) {
  console.log('start do work');
  setTimeout(cb, 1000);
  console.log('end do work');
}
doWork(callback);

 (4)XMLHttpRequest工作流程图

1、 第一步:创建 XMLHttpRequest 对象。

当执行到let xhr = new XMLHttpRequest()后,JavaScript 会创建一个 XMLHttpRequest 对象 xhr,用来执行实际的网络请求操作。

2、第二步:为 xhr 对象注册回调函数。

因为网络请求比较耗时,所以要注册回调函数,这样后台任务执行完成之后就会通过调用回调函数来告诉其执行结果。

XMLHttpRequest 的回调函数主要有下面几种:

  • ontimeout,用来监控超时请求,如果后台请求超时了,该函数会被调用;
  • onerror,用来监控出错信息,如果后台请求出错了,该函数会被调用;onreadystatechange,用来监控后台请求过程中的状态,比如可以监控到 HTTP 头加载完成的消息、HTTP 响应体消息以及数据加载完成的消息等。

 3、第三步:配置基础的请求信息。

  • open 接口配置一些基础的请求信息,包括请求的地址、请求方法(是 get 还是 post)和请求方式(同步还是异步请求)。
  • xhr 内部属性类配置一些其他可选的请求信息,如:xhr.timeout = 3000来配置超时时间,xhr.responseType = "text"来配置服务器返回的格式,将服务器返回的数据自动转换为自己想要的格式,xhr.setRequestHeader 来添加自己专用的请求头属性。

4、发起请求

一切准备就绪之后,就可以调用xhr.send来发起网络请求了

  • 渲染进程会将请求发送给网络进程,然后网络进程负责资源的下载,等网络进程接收到数据之后,就会利用 IPC 来通知渲染进程;
  • 渲染进程接收到消息之后,会将 xhr 的回调函数封装成任务并添加到消息队列中,等主线程循环系统执行到该任务的时候,就会根据相关的状态来调用对应的回调函数。
  1. 如果网络请求出错了,就会执行 xhr.onerror;
  2. 如果超时了,就会执行 xhr.ontimeout;
  3. 如果是正常的数据接收,就会执行 onreadystatechange 来反馈相应的状态。

(5) XMLHttpRequest 使用过程中的“坑”

1、跨域问题

2、HTTPS 混合内容的问题

  • HTTPS 混合内容是 HTTPS 页面中包含了不符合 HTTPS 安全要求的内容,比如包含了 HTTP 资源,通过 HTTP 加载的图像、视频、样式表、脚本等,都属于混合内容。
  • 通常,如果 HTTPS 请求页面中使用混合内容,浏览器会针对 HTTPS 混合内容显示警告,用来向用户表明此 HTTPS 页面包含不安全的资源。

六、宏任务

(1) 页面中的大部分任务都是在主线程上执行的,这些任务包括了:

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。

为了协调这些任务有条不紊地在主线程上执行,页面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个 for 循环,不断地从这些任务队列中取出任务并执行任务。我们把这些消息队列中的任务称为宏任务

 (2)WHATWG 规范定义的大致流程:

  • 先从多个消息队列中选出一个最老的任务,这个任务称为 oldestTask;
  • 然后循环系统记录任务开始执行的时间,并把这个 oldestTask 设置为当前正在执行的任务;当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个 oldestTask;
  • 最后统计执行完成的时长等信息。

 (3)宏任务的时间粒度比较大,执行的时间间隔是不能精确控制的,对一些高实时性的需求就不太符合了,比如后面要介绍的监听 DOM 变化的需求

五、微任务

 (1)微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前

(2)V8 引擎的层面分析:

当 JavaScript 执行一段脚本的时候,V8 会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8 引擎也会在内部创建一个微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给 V8 引擎内部使用的,所以你是无法通过 JavaScript 直接访问的。

(3)每个宏任务都关联了一个微任务队列。

(4)产生微任务有两种方式

  • 第一种方式是使用 MutationObserver 监控某个 DOM 节点,然后再通过 JavaScript 来修改这个节点,或者为这个节点添加、删除部分子节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  • 第二种方式是使用 Promise,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

 (5)微任务队列何时被执行

在当前宏任务中的 JavaScript 快执行完成时,也就在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG 把执行微任务的时间点称为检查点。

(6)如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。也就是说在执行微任务过程中产生的新的微任务并不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

 1、该示意图是在执行一个 ParseHTML 的宏任务,在执行过程中,遇到了 JavaScript 脚本,那么就暂停解析流程,进入到 JavaScript 的执行环境。从图中可以看到,全局上下文中包含了微任务列表。

2、在 JavaScript 脚本的后续执行过程中,分别通过 Promise 和 removeChild 创建了两个微任务,并被添加到微任务列表中。接着 JavaScript 执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript 引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文。

(7) 结论

  • 微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列
  • 微任务的执行时长会影响到当前宏任务的时长。比如一个宏任务在执行过程中,产生了 100 个微任务,执行每个微任务的时间是 10 毫秒,那么执行这 100 个微任务的时间就是 1000 毫秒,也可以说这 100 个微任务让宏任务的执行时间延长了 1000 毫秒。所以你在写代码的时候一定要注意控制微任务的执行时长。
  • 在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。

 (8)MutationObserver 采用了“异步 + 微任务”的策略

  • 通过异步操作解决了同步操作的性能问题;
  • 通过微任务解决了实时性的问题。

 六、Promise

 (1)产生回调地狱的原因

1、多层嵌套的问题;

2、每种任务的处理结果存在两种可能性(成功或失败),那么需要在每种任务执行结束后分别处理这两种可能性。

(2)Promise 通过回调函数延迟绑定、回调函数返回值穿透和错误“冒泡”技术解决了上面的两个问题。

(3)Promise 之所以要使用微任务是由 Promise 回调函数延迟绑定技术导致的。

八、 async/await(用同步代码的风格来编写异步代码)

 (1)ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力,并且使得代码逻辑更加清晰。

(2)生成器函数

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

function* genDemo() {
  console.log('开始执行第一段');
  yield 'generator 2';

  console.log('开始执行第二段');
  yield 'generator 2';

  console.log('开始执行第三段');
  yield 'generator 2';

  console.log('执行结束');
  return 'generator 2';
}

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');
  •  在生成器函数内部执行一段代码,如果遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该函数的执行。
  • 外部函数可以通过 next 方法恢复函数的执行。

(3) 协程

1、协程是一种比线程更加轻量级的存在。

2、以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程,比如当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。

3、协程不是被操作系统内核所xiec管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

4、协程执行流程图

 5、协程的四点规则:

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

 6、V8 是如何切换到协程的调用栈

  • 第一点:gen 协程和父协程是在主线程上交互执行的,并不是并发执行的,它们之前的切换是通过 yield 和 gen.next 来配合完成的。
  • 第二点:当在 gen 协程中调用了 yield 方法时,JavaScript 引擎会保存 gen 协程当前的调用栈信息,并恢复父协程的调用栈信息。同样,当在父协程中执行 gen.next 时,JavaScript 引擎会保存父协程的调用栈信息,并恢复 gen 协程的调用栈信息。

 7、生成器就是协程的一种实现方式。

(4)async/await 技术背后的秘密就是 Promise 和生成器应用,往低层说就是微任务和协程应用。

  • async

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

  • await

async function foo() {
    console.log(1)
    let a = await 100
    console.log(a)
    console.log(2)
}
console.log(0)
foo()
console.log(3)

执行流程图

 1、当执行到await 100时,会默认创建一个 Promise 对象

let promise_ = new Promise((resolve,reject){
  resolve(100)
})

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

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

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

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

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

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

四、问题

 (1)用CSS3实现动画是不是不会影响主线程,和用JS实现动画会影响主线程,这个说法对么?

        是这样的,部分css3的动画效果是在合成线程上实现的,不需要主线程介入,所以省去了重拍和重绘的过程,这就大大提升了渲染效率。

        JavaScript都是在在主线程上执行的,所以JavaScript的动画需要主线程的参与,所以效率会大打折扣!

(2)requestAnimationFrame 实现的动画效果比 setTimeout 好的原因。

        requestAnimationFrame 提供一个原生的API去执行动画的效果,它会在一帧(一般是16ms)间隔内根据选择浏览器情况去执行相关动作。 setTimeout是在特定的时间间隔去执行任务,不到时间间隔不会去执行,这样浏览器就没有办法去自动优化。

(3)requestAnimationFrame 也是在主线程上执行吗?如果当前任务执行时间过久,也会导致 requestAnimationFrame 被延后执行吗?

        是的,raf的回调函数也是在主线程上执行的,如果其中的一个回调函数执行过久,会影响到其他的任务的

(4)延迟队列和消息队列是什么关系,怎么配合工作的?

        延迟消息队列主要是放一些定时执行的任务,如JavaScript设置定时器的回调,还有浏览器内部的一些定时回调任务! 这类任务需要等到指定时间间隔之后才会被执行!

        而正常的消息队列中的任务只会按照顺序执行,执行完上个任务接着执行下个任务,不需要关系时间间隔!

(5)执行延迟队列的任务,是一次循环只取出一个,还是检查只要时间到了,就执行? 2.微任务是在宏任务里的,是执行完一个宏任务,就去执行宏任务里面的微任务?

        比如有五个定时的任务到期了,那么会分别把这个五个定时器的任务执行掉,再开始下次循环过程!

        chromium中,当执行一个宏任务时,才会创建微任务队列,等遇到checkpoint时就会执行微任务!

(6)建立tcp连接是在xhr open还是send?

open方法仅仅是配置数据,没有任何真实的连接产生,所有连接阶段都是在send之后

 (7)es6中可以通过一个 fetch api来请求,它的实现是用了 xmlHttpRequest么?如果不是,原理上有什么不同?

        fetch采用了promise来封装,在使用方式上更强现代化,同时还原生支持async/await。在chromium中,fetch是完全重新实现的,和xmlhttprequest没有什么关系! 在项目中推荐使用fetch

 (8)Promise 中为什么要引入微任务?

        由于promise采用.then延时绑定回调机制,而new Promise时又需要直接执行promise中的方法,即发生了先执行方法后添加回调的过程,此时需等待then方法绑定两个回调后才能继续执行方法回调,便可将回调添加到当前js调用栈中执行结束后的任务队列中,由于宏任务较多容易堵塞,则采用了微任务

(9)Promise 中是如何实现回调函数返回值穿透的?

        首先Promise的执行结果保存在promise的data变量中,然后是.then方法返回值为使用resolved或rejected回调方法新建的一个promise对象,即例如成功则返回new Promise(resolved),将前一个promise的data值赋给新建的promise

(10)Promise 出错后,是怎么通过“冒泡”传递给最后那个捕获

        promise内部有resolved_和rejected_变量保存成功和失败的回调,进入.then(resolved,rejected)时会判断rejected参数是否为函数,若是函数,错误时使用rejected处理错误;若不是,则错误时直接throw错误,一直传递到最后的捕获,若最后没有被捕获,则会报错。可通过监听unhandledrejection事件捕获未处理的promise错误(判断rejected参数是否为函数,若是函数,表示注册了失败的回调,直接执行,若不是函数,表示没有注册失败的回调函数,则直接reject掉then产生的promise,就可以一直传递下去,直到找到一个注册了失败的回调函数,catch实际是只注册了失败回调函数的then)

(11)异步AJAX请求是宏任务吧?Promise是微任务,那么用Promise进行的异步Ajax调用时宏任务还是微任务?

        ajax就是xmlhttprequest,必然是宏任务!

        准确地说,Promise在执行resolve或者reject时,触发微任务,所以在Promise的executor函数中调用xmlhttprequest会触发宏任务。

        如果xmlhttprequest请求成功了,通过resolve触发微任务。

        如果xmlhttprequest请求失败了,通过reject触发微任务。

(12)输出结果

async function foo() {
    console.log('foo')
}
async function bar() {
    console.log('bar start')
    await foo()
    console.log('bar end')
}
console.log('script start')
setTimeout(function () {
    console.log('setTimeout')
}, 0)
bar();
new Promise(function (resolve) {
    console.log('promise executor')
    resolve();
}).then(function () {
    console.log('promise then')
})
console.log('script end')

1. 首先在主协程中初始化异步函数foo和bar,碰到console.log打印script start;

2. 解析到setTimeout,初始化一个Timer,创建一个新的task

3. 执行bar函数,将控制权交给协程,输出bar start,碰到await,执行foo,输出foo,创建一个 Promise返回给主协程

4. 将返回的promise添加到微任务队列,向下执行 new Promise,输出 promise executor,返回resolve 添加到微任务队列

5. 输出script end

6. 当前task结束之前检查微任务队列,执行第一个微任务,将控制器交给协程输出bar end

7. 执行第二个微任务 输出 promise then

8. 当前任务执行完毕进入下一个任务,输出setTimeout

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值