React源码解析笔记---调度更新(二)

前言:为了搞清楚react到底是个什么样的“框架”以及它的内部机制,因此开始阅读react源码。在阅读源码的时候,为了梳理流程,根据自己的理解以及网上的一些资料做了一些(大致流程的)笔记。笔记是当时的理解,写的时候,仍然有很多不理解的地方待后续完善。而写出来的部分,可能会有很多理解不到位甚至是错误的地方,一旦有新的理解或者发现了错误,会补充与修正。

react源码版本:16.8.6

上篇:React源码解析笔记—调度更新(一)

接下来到了 scheduleWork
细节部分在代码注释里,重要的流程提出来了。

export function scheduleUpdateOnFiber(
  fiber: Fiber,
  expirationTime: ExpirationTime,
) {
  // 判断有没有嵌套更新
  checkForNestedUpdates();
  warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber);
  // 获取FiberRoot对象
  const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);
  // 如果root为空就说明没找到FiberRoot直接中断任务
  if (root === null) {
    warnAboutUpdateOnUnmountedFiberInDEV(fiber);
    return;
  }

  root.pingTime = NoWork;
 // 判断当前任务是否被打断
 // 如果有则用interruptedBy标记打断其他任务的Fiber,在开发模式下,renderRoot的时候给予提醒
  checkForInterruption(fiber, expirationTime);
  
  recordScheduleUpdate();
  // 判断expirationTime是否为Sync
  if (expirationTime === Sync) { // 同步任务
    if (workPhase === LegacyUnbatchedPhase) {
      // 初次挂载root
      // This is a legacy edge case. The initial mount of a ReactDOM.render-ed
      // root inside of batchedUpdates should be synchronous, but layout updates
      // should be deferred until the end of the batch.
      let callback = renderRoot(root, Sync, true);
      while (callback !== null) {
        callback = callback(true);
      }
    } else {
      // 需要立即执行的同步任务
      scheduleCallbackForRoot(root, ImmediatePriority, Sync);
      if (workPhase === NotWorking) {
        // 刷新同步任务队列
        // Flush the synchronous work now, unless we're already working or inside
        // a batch. This is intentionally inside scheduleUpdateOnFiber instead of
        // scheduleCallbackForFiber to preserve the ability to schedule a callback
        // without immediately flushing it. We only do this for user-initated
        // updates, to preserve historical behavior of sync mode.
        flushImmediateQueue();
      }
    }
  } else { // 异步任务
    // TODO: computeExpirationForFiber also reads the priority. Pass the
    // priority as an argument to that function and this one.
    const priorityLevel = getCurrentPriorityLevel();
    if (priorityLevel === UserBlockingPriority) {
      // This is the result of a discrete event. Track the lowest priority
      // discrete update per root so we can flush them early, if needed.
      if (rootsWithPendingDiscreteUpdates === null) {
        rootsWithPendingDiscreteUpdates = new Map([[root, expirationTime]]);
      } else {
        const lastDiscreteTime = rootsWithPendingDiscreteUpdates.get(root);
        if (
          lastDiscreteTime === undefined ||
          lastDiscreteTime > expirationTime
        ) {
          rootsWithPendingDiscreteUpdates.set(root, expirationTime);
        }
      }
    }
    scheduleCallbackForRoot(root, priorityLevel, expirationTime);
  }
}

首先通过markUpdateTimeFromFiberToRoot(fiber, expirationTime);根据传进来的Fiber节点,向上找到FiberRoot对象:

function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
  // 更新产生更新的fiber的expirationTime
  // 如果fiber的expirationTime要小于当前的expirationTime,说明它的优先级要比当前的低
  // 把它的优先级提高到当前的优先级
  if (fiber.expirationTime < expirationTime) {
    fiber.expirationTime = expirationTime;
  }
  // 更新alternate的expirationTime,与上面一样
  let alternate = fiber.alternate;
  if (alternate !== null && alternate.expirationTime < expirationTime) {
    alternate.expirationTime = expirationTime;
  }
  // Walk the parent path to the root and update the child expiration time.
  let node = fiber.return; // 获取fiber的父节点
  let root = null;
  if (node === null && fiber.tag === HostRoot) {
    // 如果父节点不存在且tag为HostRoot则说明传入的Fiber为RootFiber(初次渲染)
    // 通过RootFiber的stateNode属性就可以获取到FiberRoot
    root = fiber.stateNode;
  } else {
    // 如果不是RootFiber,就通过循环遍历一层层向上找到FiberRoot
    while (node !== null) {
      alternate = node.alternate;
      if (node.childExpirationTime < expirationTime) {
        // 如果子节点的优先级比当前更新任务的优先级要低,就提高到当前优先级
        node.childExpirationTime = expirationTime;
        if (
          alternate !== null &&
          alternate.childExpirationTime < expirationTime
        ) {
          // 同时也更新一下alternate的子节点expirationTime
          alternate.childExpirationTime = expirationTime;
        }
      } else if (
         // 如果子节点的优先级不比当前更新任务的优先级低
         // 判断一下alternate的子节点优先级是否需要更新
        alternate !== null &&
        alternate.childExpirationTime < expirationTime
      ) {
        alternate.childExpirationTime = expirationTime;
      }
      if (node.return === null && node.tag === HostRoot) {
        // 最后获取到FiberRoot对象
        root = node.stateNode;
        break;
      }
      node = node.return;
    }
  }

  if (root !== null) {
    // 更新root树上最老以及最新的挂起时间
    // 从结果就可以看出root.firstPendingTime始终大于等于root.lastPendingTime
    const firstPendingTime = root.firstPendingTime; // root树上最早挂起时间
    if (expirationTime > firstPendingTime) {
      root.firstPendingTime = expirationTime;
    }
    const lastPendingTime = root.lastPendingTime; // root树上最新挂起时间
    if (lastPendingTime === NoWork || expirationTime < lastPendingTime) {
      root.lastPendingTime = expirationTime;
    }
  }

  return root;
}

回到主流程,接下来是判断expirationTime:
同步模式 => 是否是初次挂载 => 是 => 直接调用renderRoot渲染更新
同步模式 => 是否是初次挂载 => 否 => 调用scheduleCallbackForRoot进行callback调度
异步模式 => 获取优先级 => 调用scheduleCallbackForRoot进行callback调度

这里是调度流程,所以看一下scheduleCallbackForRoot:

function scheduleCallbackForRoot(
  root: FiberRoot,
  priorityLevel: ReactPriorityLevel,
  expirationTime: ExpirationTime,
) {
  // 获取之前与root关联的回调的时间
  const existingCallbackExpirationTime = root.callbackExpirationTime;
  if (existingCallbackExpirationTime < expirationTime) {
    // 新的回调比之前的回调优先级更高
    const existingCallbackNode = root.callbackNode;
    if (existingCallbackNode !== null) {
      // 在之前的回调优先级低的情况下
      // 在下一次更新到来的时候,取消之前的回调(因为这次的root.callbackNode是null或者为上一次的root.callbackNode)
      // 调用Schedule.js模块内的unstable_cancelCallback函数将callbackNode从firstCallbackNode这个双向链表中去掉
      cancelCallback(existingCallbackNode);
    }
    // 重置回调的过期时间为当前时间
    root.callbackExpirationTime = expirationTime;

    let options = null;
    if (expirationTime !== Sync && expirationTime !== Never) {
      let timeout = expirationTimeToMs(expirationTime) - now();
      if (timeout > 5000) {
        // Sanity check. Should never take longer than 5 seconds.
        // TODO: Add internal warning?
        timeout = 5000;
      }
      options = {timeout};
    }
    /* 返回值是一个包含以下属性的节点对象
      callback
      priorityLevel: priorityLevel
      expirationTime
      next: null
      previous: null
    */
    root.callbackNode = scheduleCallback(
      priorityLevel,
      runRootCallback.bind(
        null,
        root,
        renderRoot.bind(null, root, expirationTime),
      ),
      options,
    );
    if (
      enableUserTimingAPI &&
      expirationTime !== Sync &&
      workPhase !== RenderPhase &&
      workPhase !== CommitPhase
    ) {
      // Scheduled an async callback, and we're not already working. Add an
      // entry to the flamegraph that shows we're waiting for a callback
      // to fire.
      startRequestCallbackTimer();
    }
  }

  // Add the current set of interactions to the pending set associated with
  // this root.
  schedulePendingInteraction(root, expirationTime);
}

补充注释:


① 在取消回调的函数cancelCallback里遇到的firstCallbackNode是个双向循环链表,按照节点的过期时间(以毫秒为单位)的顺序来存储的,过期时间越长,表示越不容易过期,则节点位置越靠后,同时最后一个节点的next又会指向头,头部节点的previous会指向尾。下面贴的unstable_scheduleCallback会看到该链表的插入过程。

② expirationTimeToMs函数,它将expirationTime转化为毫秒。举个之前通过currentTime计算得到的expirationTime的例子:

currentTime = MAGIC_NUMBER_OFFSET - ((ms / UNIT_SIZE) | 0);
expirationTime = MAGIC_NUMBER_OFFSET - 
	             ceiling(
                   MAGIC_NUMBER_OFFSET - currentTime + expirationInMs / UNIT_SIZE,
                   bucketSizeMs / UNIT_SIZE,
                 )
               = MAGIC_NUMBER_OFFSET - ((((MAGIC_NUMBER_OFFSET - currentTime + expirationInMs / UNIT_SIZE) / (bucketSizeMs / UNIT_SIZE)) | 0) + 1) * (bucketSizeMs / UNIT_SIZE)
               = MAGIC_NUMBER_OFFSET - ((((((ms / 10) | 0) + expirationInMs / 10) / (bucketSizeMs / 10)) | 0) + 1) * (bucketSizeMs / 10)

expirationTimeToMs(expirationTime) 
=> (MAGIC_NUMBER_OFFSET - expirationTime) * UNIT_SIZE
=> (MAGIC_NUMBER_OFFSET - expirationTime) * 10
=>  ((((((ms / 10) | 0) + expirationInMs / 10) / (bucketSizeMs / 10)) | 0) + 1) * (bucketSizeMs / 10)*10
=> ...
=> 约等于 ((ms + expirationInMs) / bucketSizeMs + 1) * bucketSizeMs
=> +1 可以忽略不计,得到:ms + expirationInMs                

因此上面的timeout拿到的其实就是一个过期时间,设定过期时间的最大值为5秒。也就是说,5秒之后它就过期了。


代码执行接下来就到了root.callbackNode = scheduleCallback(…),scheduleCallback又调用了Scheduler_scheduleCallback将callback的节点放进链表里并进行调度:

function unstable_scheduleCallback(
  priorityLevel,
  callback,
  deprecated_options,
) {
  // startTime,这里可以当作getCurrentTime()去理解
  var startTime =
    currentEventStartTime !== -1 ? currentEventStartTime : getCurrentTime();
  // 获取过期时间,该时间是开始时间加上该任务的最大执行时间 
  var expirationTime;
  if (
    typeof deprecated_options === 'object' &&
    deprecated_options !== null &&
    typeof deprecated_options.timeout === 'number'
  ) {
    // 将expiration times从React包里分离之后,这个分支将会不存在了
    expirationTime = startTime + deprecated_options.timeout;
  } else {
    switch (priorityLevel) {
      case ImmediatePriority:
        expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
        break;
      case UserBlockingPriority:
        expirationTime = startTime + USER_BLOCKING_PRIORITY;
        break;
      case IdlePriority:
        expirationTime = startTime + IDLE_PRIORITY;
        break;
      case LowPriority:
        expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
        break;
      case NormalPriority:
      default:
        expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
    }
  }

  var newNode = {
    callback,
    priorityLevel: priorityLevel,
    expirationTime,
    next: null,
    previous: null,
  };
  
  if (firstCallbackNode === null) {
   // 如果firstCallbackNode链表中没有节点,就将新节点作为链表的第一个节点,前后都指向自己
    firstCallbackNode = newNode.next = newNode.previous = newNode;
    scheduleHostCallbackIfNeeded();
  } else {
   
    // 如果链表中有节点存在,就遍历链表,找到比新节点优先级高的节点,然后将这个新节点插入到该节点前面
    var next = null;
    var node = firstCallbackNode;
    do {
      if (node.expirationTime > expirationTime) {
        // The new callback expires before this one.
        next = node;
        break;
      }
      node = node.next;
    } while (node !== firstCallbackNode);

    if (next === null) {
      // 没有找到比新节点的expirationTime更大的节点就给next赋值为first节点
      // No callback with a later expiration was found, which means the new
      // callback has the latest expiration in the list.
      next = firstCallbackNode;
    } else if (next === firstCallbackNode) {
      // 如果next是第一个节点,那么就将新节点赋值为第一个节点,
      // 到最后再将原来的第一个节点插到新节点后面
      firstCallbackNode = newNode;
      scheduleHostCallbackIfNeeded();
    }
    
    // 改变前后节点指针,将新节点插入到firstCallbackNode链表中
    var previous = next.previous;
    previous.next = next.previous = newNode;
    newNode.next = next;
    newNode.previous = previous;
  }

  return newNode;
}

从上面的源码可以看到,如果双向链表的第一个节点改变了,就会调用scheduleHostCallbackIfNeeded,这个函数的作用是让队列进入调度的过程:

function scheduleHostCallbackIfNeeded() {
  if (isPerformingWork) {
    // 如果已经在调度状态了,就直接返回,防止二次进入
    // Don't schedule work yet; wait until the next time we yield.
    return;
  }
  if (firstCallbackNode !== null) {
    // Schedule the host callback using the earliest expiration in the list.
    // 取第一个最容易过期的任务
    var expirationTime = firstCallbackNode.expirationTime;
    if (isHostCallbackScheduled) {
      // 当前的callback已经被调度了
      // 将scheduledHostCallback设为null,停止当前requestAnimationFrameWithTimeout的调用
      cancelHostCallback();
    } else {
      isHostCallbackScheduled = true;
    }
    // 调用requestHostCallback进入react的更新与浏览器的渲染阶段
    requestHostCallback(flushWork, expirationTime);
  }
}


requestHostCallback = function(callback, absoluteTimeout) {
  // 将flushWork以及任务的过期时间赋值给全局变量
   scheduledHostCallback = callback;
   timeoutTime = absoluteTimeout;
   if (isFlushingHostCallback || absoluteTimeout < 0) {
     // 如果是正在进行callback调用或者任务要过期了,不需要等待下一帧,立马把任务推到任务队列中
     port.postMessage(undefined);
   } else if (!isAnimationFrameScheduled) {
     // 如果调度任务还没开始,就开始调度
     isAnimationFrameScheduled = true;
     requestAnimationFrameWithTimeout(animationTick);
   }
 };

补充说明:


① isFlushingHostCallback正在进行callback调用,说明这一帧内的浏览器主线程任务已经完成了,现在处于空闲状态,可以执行callback了,因此不需要等待下一帧开始就可以把任务直接放进任务队列中了。

② 上面的requestHostCallback定义是针对浏览器环境的,对于非浏览器环境,react也有另外一种实现。


再来看一下requestAnimationFrameWithTimeout以及参数animationTick的实现代码:

const requestAnimationFrameWithTimeout = function(callback) {
  // schedule rAF and also a setTimeout
  rAFID = localRequestAnimationFrame(function(timestamp) {
    // cancel the setTimeout
    localClearTimeout(rAFTimeoutID);
    callback(timestamp);
  });
  rAFTimeoutID = localSetTimeout(function() {
    // cancel the requestAnimationFrame
    localCancelAnimationFrame(rAFID);
    callback(getCurrentTime());
  }, ANIMATION_FRAME_TIMEOUT);
};

// 其实它的作用就是适配浏览器的刷新频率以及将任务推进队列
const animationTick = function(rafTime) {
    if (scheduledHostCallback !== null) {
      // Eagerly schedule the next animation callback at the beginning of the
      // frame. If the scheduler queue is not empty at the end of the frame, it
      // will continue flushing inside that callback. If the queue *is* empty,
      // then it will exit immediately. Posting the callback at the start of the
      // frame ensures it's fired within the earliest possible frame. If we
      // waited until the end of the frame to post the callback, we risk the
      // browser skipping a frame and not firing the callback until the frame
      // after that.
      requestAnimationFrameWithTimeout(animationTick);
    } else {
      // No pending work. Exit.
      isAnimationFrameScheduled = false;
      return;
    }
    // 指定下一帧的时间
    let nextFrameTime = rafTime - frameDeadline + activeFrameTime;
    if (
      nextFrameTime < activeFrameTime &&
      previousFrameTime < activeFrameTime
    ) {
      if (nextFrameTime < 8) {
        // Defensive coding. We don't support higher frame rates than 120hz.
        // If the calculated frame time gets lower than 8, it is probably a bug.
        nextFrameTime = 8;
      }
      // If one frame goes long, then the next one can be short to catch up.
      // If two frames are short in a row, then that's an indication that we
      // actually have a higher frame rate than what we're currently optimizing.
      // We adjust our heuristic dynamically accordingly. For example, if we're
      // running on 120hz display or 90hz VR display.
      // Take the max of the two in case one of them was an anomaly due to
      // missed frame deadlines.
      activeFrameTime =
        nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
    } else {
      previousFrameTime = nextFrameTime;
    }
    frameDeadline = rafTime + activeFrameTime;
    if (!isMessageEventScheduled) {
      isMessageEventScheduled = true;
      port.postMessage(undefined);
    }
  };

requestAnimationFrameWithTimeout 中先是调用了localRequestAnimationFrame函数,这个函数其实就是window.requestAnimationFrame(callback)方法,让浏览器在下一次重绘之前调用该函数的callback。函数内部先清除了一个定时器,然后调用animationTick方法。设置定时器rAFTimeoutID是为了防止localRequestAnimationFrame函数在指定时间ANIMATION_FRAME_TIMEOUT(100毫秒)内没有执行,然后可以主动调用animationTick。所以它们的目的都是为了在一定时间内调用animationTick,这个函数的作用其实就是适配浏览器的刷新频率以及将任务推进队列。

animationTick中首先判断在上一帧开始时调度队列是否为空,如果不为空,就指定下一帧的动画回调。这么做是为了让回调尽早的触发。如果到上一帧结束才指定,可能会丢失一帧的回调执行。

activeFrameTime,frameDeadline
判断完成之后,指定下一帧的时间:let nextFrameTime = rafTime - frameDeadline + activeFrameTime;
这里rafTime其实就是window.requestAnimationFrame给回调函数传入的一个时间戳,表示回调函数被执行的时间。frameDeadline表示在经过(假定或者调整的)浏览器每帧所用时间之后的时间戳,即,下一帧开始本应该处于什么时间,初始值为0。
activeFrameTime为33,表示浏览器每一帧刷新所用的时间。即第一次计算的下一帧开始的时间是当前开始时间+假定的每一帧所用时间。

接下来的if就先不用看了,第一次nextFrameTime和previousFrameTime肯定不会小于activeFrameTime。else内将nextFrameTime赋值给previousFrameTime,表示第一帧所用时间。然后重新计算了一下frameDeadline。最后利用postMessage将消息放js的进任务队列中,等到在每一帧的react更新执行以及浏览器渲染完成之后的空闲状态下调用任务队列。

到第二次调用animationTick的时候,此时如果队列仍然不为空,再次来到计算nextFrameTime的地方。nextFrameTime = rafTime - frameDeadline + activeFrameTime;

rafTime - frameDeadline表示当前帧开始时间与计划的当前帧开始时间的时间差,再加上假设的activeFrameTime。如果连续两帧的计算结果都小于activeFrameTime,就表明上一帧的所用时间要小于假设的activeFrameTime,浏览器的刷新频率更高。因此要重新计算activeFrameTime以匹配浏览器的刷新频率。

postMessage, onMessage
react 利用window.postMessage()将消息放进任务队列中,浏览器处于每一帧的空闲状态之后,就开始处理消息。窗口接收到消息,执行onmessage函数:

channel.port1.onmessage = function(event) {
   isMessageEventScheduled = false;
   // 记录回调函数与过期时间
   const prevScheduledCallback = scheduledHostCallback;
   const prevTimeoutTime = timeoutTime;
   scheduledHostCallback = null;
   timeoutTime = -1;
   // 获取当前时间
   const currentTime = getCurrentTime();
  
   let didTimeout = false;  // 是否过期的标识
   if (frameDeadline - currentTime <= 0) { // 说明这一帧的时间已经用完了,没有空闲时间了
     // There's no time left in this idle period. Check if the callback has
     // a timeout and whether it's been exceeded.
     if (prevTimeoutTime !== -1 && prevTimeoutTime <= currentTime) {
       // 任务已经过期
       didTimeout = true;
     } else {
       // 没有过期的情况下,说明当前帧没有任务要执行,继续下一帧的动作
       if (!isAnimationFrameScheduled) {
         // Schedule another animation callback so we retry later.
         isAnimationFrameScheduled = true;
         requestAnimationFrameWithTimeout(animationTick);
       }
       // Exit without invoking the callback.
       scheduledHostCallback = prevScheduledCallback;
       timeoutTime = prevTimeoutTime;
       return;
     }
   }

   if (prevScheduledCallback !== null) {
     isFlushingHostCallback = true;
     try {
       // 调用回调函数
       prevScheduledCallback(didTimeout);
     } finally {
       isFlushingHostCallback = false;
     }
   }
 };

看到最后是调用回调函数,回调函数之前传进来的是flushWork。这个函数的作用其实就是在执行过期任务或者下一帧开始之前去执行队列里面的任务:

function flushWork(didUserCallbackTimeout) {
  // Exit right away if we're currently paused
  if (enableSchedulerDebugging && isSchedulerPaused) {
    return;
  }

  // We'll need a new host callback the next time work is scheduled.
  isHostCallbackScheduled = false;

  isPerformingWork = true;
  const previousDidTimeout = currentHostCallbackDidTimeout;
  currentHostCallbackDidTimeout = didUserCallbackTimeout;
  try {
    if (didUserCallbackTimeout) {
      // 有任务过期了,立即更新所有的过期任务
      while (
        firstCallbackNode !== null &&
        !(enableSchedulerDebugging && isSchedulerPaused)
      ) {
        var currentTime = getCurrentTime();
        if (firstCallbackNode.expirationTime <= currentTime) {
          // 链表的第一个节点的任务已经过期了
          // 然后将链表中所有已过期的任务都取出执行
          do {
            flushFirstCallback();
          } while (
            firstCallbackNode !== null &&
            firstCallbackNode.expirationTime <= currentTime &&
            !(enableSchedulerDebugging && isSchedulerPaused)
          );
          continue;
        }
        break;
      }
    } else {
      // 没有任务过期,在这一帧时间结束之前刷新任务.
      if (firstCallbackNode !== null) {
        do {
          if (enableSchedulerDebugging && isSchedulerPaused) {
            break;
          }
          flushFirstCallback();
        } while (firstCallbackNode !== null && !shouldYieldToHost());
      }
    }
  } finally {
    isPerformingWork = false;
    currentHostCallbackDidTimeout = previousDidTimeout;
    // 再次调用,在下一帧开始时继续进行浏览器的渲染以及调度工作
    // 因为之前把scheduledHostCallback设置为了null,因此动画停止了
    scheduleHostCallbackIfNeeded();
  }
}

补充注释:
这里面有用到flushFirstCallback方法,它的作用是取出第一个节点并清空它的前后指针,将下一个节点作为第一个节点。再判断下判断回调是否过期(这里把立即更新的任务也当作过期任务来处理),然后调用之前传进来的runRootCallback回调函数去进行root渲染更新,取它的返回值,如果返回值是一个函数,就把这个函数当作节点的回调组成一个新的节点任务,然后按顺序插入链表。大致流程就是这样,这里插入节点的代码与unstable_scheduleCallback函数中插入节点的方法类似。

//
调度到这里就差不多了,接下来就是更新渲染…

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值