React 深度学习:4. 创建 DOM 元素

本文介绍了React初次渲染时创建DOM元素的过程,包括createElement、createTextNode、setInitialProperties等方法的详细作用。讨论了如何通过递归方式构建DOM树,并解释了React如何处理属性和事件,特别是对html字符串的特殊处理。同时提到了元素更新阶段的相关方法,如updateProperties和diffProperties。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

React 初次渲染的时候无非会经历如下四个步骤:


ReactElement 元素的创建,以及它如何表达嵌套在上个章节中也已阐述。React 的难点就在于如何进行数据处理,并且保持高效,我们暂且忽略它。秉持由浅入深的原则进行学习。

想要从 ReactElement 元素的层层嵌套结构中解析出 DOM,能直接想到的办法就是使用递归。在遇到每一层的时候根据元素类型创建不同的 DOM 元素,最后将这一个庞大的 DOM 元素树插入到真实的 DOM 中。

这些功能,都集中在:

packages/react-dom/src/client/ReactDOMComponent.js

createElement

/**
 * 创建元素
 * @param type
 * @param props
 * @param rootContainerElement
 * @param parentNamespace
 * @returns {Element}
 */
export function createElement(
  type: string,
  props: Object,
  rootContainerElement: Element | Document,
  parentNamespace: string,
): Element {
  let isCustomComponentTag;

  // 我们在它们的父容器的命名空间中创建标记,除了 HTML 标签没有命名空间。
  // getOwnerDocumentFromRootContainer 方法用来获取 document
  const ownerDocument: Document = getOwnerDocumentFromRootContainer(
    rootContainerElement,
  );
  let domElement: Element;
  let namespaceURI = parentNamespace;
  if (namespaceURI === HTML_NAMESPACE) {
    namespaceURI = getIntrinsicNamespace(type);
  }
  if (namespaceURI === HTML_NAMESPACE) {

    if (type === 'script') {
      // 通过 .innerHTML 创建脚本,这样它的 “parser-inserted” 标志就被设置为true,而且不会执行
      const div = ownerDocument.createElement('div');
      div.innerHTML = '<script><' + '/script>'; // eslint-disable-line
      // 这将保证生成一个脚本元素。
      const firstChild = ((div.firstChild: any): HTMLScriptElement);
      domElement = div.removeChild(firstChild);
    } else if (typeof props.is === 'string') {
      // $FlowIssue `createElement` 应该为 Web Components 更新
      domElement = ownerDocument.createElement(type, {is: props.is});
    } else {
      // 因为 Firefox bug,分离 else 分支,而不是使用 `props.is || undefined`
      // 参见 https://github.com/facebook/react/pull/6896
      // 和 https://bugzilla.mozilla.org/show_bug.cgi?id=1276240
      domElement = ownerDocument.createElement(type);
      // 通常在 `setInitialDOMProperties` 中分配属性,
      // 但是 `select` 上的 `multiple` 和 `size` 属性需要在 `option` 被插入之前添加。
      // 这可以防止:
      // 一个错误,其中 `select` 不能滚动到正确的选项,因为单一的 `select` 元素自动选择第一项 #13222
      // 一个 bug,其中 `select` 将第一个项目设置为 selected,而忽略 `size` 属性 #14239
      // 参见 https://github.com/facebook/react/issues/13222
      // 和 https://github.com/facebook/react/issues/14239
      if (type === 'select') {
        const node = ((domElement: any): HTMLSelectElement);
        if (props.multiple) {
          node.multiple = true;
        } else if (props.size) {
          // 设置大于 1 的 size 会使 select 的行为类似于 `multiple=true`,其中可能没有选择任何选项。
          // select 只有在“单一选择模式”下才需要这样做。
          node.size = props.size;
        }
      }
    }
  } else {
    domElement = ownerDocument.createElementNS(namespaceURI, type);
  }

  return domElement;
}复制代码

createElement 方法会根据不同的类型创建不同的 DOM 元素。值得指出的是,它不仅支持原生的 html 标签,还支持 WebComponent。

createTextNode

export function createTextNode(
  text: string,
  rootContainerElement: Element | Document,
): Text {
  return getOwnerDocumentFromRootContainer(rootContainerElement).createTextNode(
    text,
  );
}复制代码

createTextNode 方法创建了一个文本节点

setInitialProperties

export function setInitialProperties(
  domElement: Element,
  tag: string,
  rawProps: Object,
  rootContainerElement: Element | Document,
): void {
  const isCustomComponentTag = isCustomComponent(tag, rawProps);

  // TODO: Make sure that we check isMounted before firing any of these events.
  let props: Object;
  switch (tag) {
    case 'iframe':
    case 'object':
      trapBubbledEvent(TOP_LOAD, domElement);
      props = rawProps;
      break;
    case 'video':
    case 'audio':
      // Create listener for each media event
      for (let i = 0; i < mediaEventTypes.length; i++) {
        trapBubbledEvent(mediaEventTypes[i], domElement);
      }
      props = rawProps;
      break;
    case 'source':
      trapBubbledEvent(TOP_ERROR, domElement);
      props = rawProps;
      break;
    case 'img':
    case 'image':
    case 'link':
      trapBubbledEvent(TOP_ERROR, domElement);
      trapBubbledEvent(TOP_LOAD, domElement);
      props = rawProps;
      break;
    case 'form':
      trapBubbledEvent(TOP_RESET, domElement);
      trapBubbledEvent(TOP_SUBMIT, domElement);
      props = rawProps;
      break;
    case 'details':
      trapBubbledEvent(TOP_TOGGLE, domElement);
      props = rawProps;
      break;
    case 'input':
      ReactDOMInputInitWrapperState(domElement, rawProps);
      props = ReactDOMInputGetHostProps(domElement, rawProps);
      trapBubbledEvent(TOP_INVALID, domElement);
      // For controlled components we always need to ensure we're listening
      // to onChange. Even if there is no listener.
      ensureListeningTo(rootContainerElement, 'onChange');
      break;
    case 'option':
      ReactDOMOptionValidateProps(domElement, rawProps);
      props = ReactDOMOptionGetHostProps(domElement, rawProps);
      break;
    case 'select':
      ReactDOMSelectInitWrapperState(domElement, rawProps);
      props = ReactDOMSelectGetHostProps(domElement, rawProps);
      trapBubbledEvent(TOP_INVALID, domElement);
      // For controlled components we always need to ensure we're listening
      // to onChange. Even if there is no listener.
      ensureListeningTo(rootContainerElement, 'onChange');
      break;
    case 'textarea':
      ReactDOMTextareaInitWrapperState(domElement, rawProps);
      props = ReactDOMTextareaGetHostProps(domElement, rawProps);
      trapBubbledEvent(TOP_INVALID, domElement);
      // For controlled components we always need to ensure we're listening
      // to onChange. Even if there is no listener.
      ensureListeningTo(rootContainerElement, 'onChange');
      break;
    default:
      props = rawProps;
  }

  assertValidProps(tag, props);

  setInitialDOMProperties(
    tag,
    domElement,
    rootContainerElement,
    props,
    isCustomComponentTag,
  );

  switch (tag) {
    case 'input':
      // TODO: Make sure we check if this is still unmounted or do any clean
      // up necessary since we never stop tracking anymore.
      track((domElement: any));
      ReactDOMInputPostMountWrapper(domElement, rawProps, false);
      break;
    case 'textarea':
      // TODO: Make sure we check if this is still unmounted or do any clean
      // up necessary since we never stop tracking anymore.
      track((domElement: any));
      ReactDOMTextareaPostMountWrapper(domElement, rawProps);
      break;
    case 'option':
      ReactDOMOptionPostMountWrapper(domElement, rawProps);
      break;
    case 'select':
      ReactDOMSelectPostMountWrapper(domElement, rawProps);
      break;
    default:
      if (typeof props.onClick === 'function') {
        // TODO: This cast may not be sound for SVG, MathML or custom elements.
        trapClickOnNonInteractiveElement(((domElement: any): HTMLElement));
      }
      break;
  }
}复制代码

trapBubbledEvent 为元素添加事件。

setInitialDOMProperties

/**
 * 设置初始 DOM 属性
 * @param tag
 * @param domElement
 * @param rootContainerElement
 * @param nextProps
 * @param isCustomComponentTag
 */
function setInitialDOMProperties(
  tag: string,
  domElement: Element,
  rootContainerElement: Element | Document,
  nextProps: Object,
  isCustomComponentTag: boolean,
): void {
  for (const propKey in nextProps) {
    if (!nextProps.hasOwnProperty(propKey)) {
      continue;
    }
    // 当前遍历的属性值
    const nextProp = nextProps[propKey];
    // 设置默认 style 标签的属性
    if (propKey === STYLE) {
      if (__DEV__) {
        if (nextProp) {
          // Freeze the next style object so that we can assume it won't be
          // mutated. We have already warned for this in the past.
          Object.freeze(nextProp);
        }
      }
      // 使用 node.style['cssFloat'] 这样的对象语法来为节点设置样式
      // 依赖于 `updateStylesByID` 而不是 `styleUpdates`.
      setValueForStyles(domElement, nextProp);
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) { // 单独处理 html 字符串
      const nextHtml = nextProp ? nextProp[HTML] : undefined;
      if (nextHtml != null) {
        setInnerHTML(domElement, nextHtml); // 设置 innerHTML
      }
    } else if (propKey === CHILDREN) { // 处理 children 属性
      if (typeof nextProp === 'string') {
        // 避免在文本为空时设置初始文本内容。
        // 在IE11中,在 <textarea> 上设置 textContent
        // 将导致占位符(placeholder)不显示在 <textarea> 中,直到它再次被 focus 和 blur。
        // https://github.com/facebook/react/issues/6731#issuecomment-254874553
        const canSetTextContent = tag !== 'textarea' || nextProp !== '';
        if (canSetTextContent) {
          setTextContent(domElement, nextProp); // 设置文本
        }
      } else if (typeof nextProp === 'number') {
        setTextContent(domElement, '' + nextProp); // 设置文本
      }
    } else if (
      propKey === SUPPRESS_CONTENT_EDITABLE_WARNING ||
      propKey === SUPPRESS_HYDRATION_WARNING
    ) {
      // Noop
    } else if (propKey === AUTOFOCUS) {
      // We polyfill it separately on the client during commit.
      // We could have excluded it in the property list instead of
      // adding a special case here, but then it wouldn't be emitted
      // on server rendering (but we *do* want to emit it in SSR).
    } else if (registrationNameModules.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        if (__DEV__ && typeof nextProp !== 'function') {
          warnForInvalidEventListener(propKey, nextProp);
        }
        ensureListeningTo(rootContainerElement, propKey); // 事件监听
      }
    } else if (nextProp != null) {
      // 设置属性值
      setValueForProperty(domElement, propKey, nextProp, isCustomComponentTag);
    }
  }
}复制代码

setInitialDOMProperties 用于设置 DOM 元素初始的属性。

  • 首先,它会遍历所有的属性,过滤掉非自身的属性
  • 处理 dangerouslySetInnerHTML 属性,该属性接收一个 { __html: '' } 格式的对象,因此这里取出其 __html,使用   setInnerHTML  方法将其设置为 innerHTML
  • 处理字符串和数字文本,使用文本节点的 .nodeValue 属性设置其文本内容
  • 处理事件处理函数属性
  • 使用  setValueForProperty  方法设置普通属性的值

其中有意思的是 React 处理 html 字符串的方式,将其放置在了 svg 标签中。为何如此处理?

const setInnerHTML = createMicrosoftUnsafeLocalFunction(function(
  node: Element,
  html: string,
): void {

  // IE 没有针对 SVG 节点的 innerHTML,
  // 因此我们将新标记注入临时节点,然后将子节点移动到目标节点
  // 也就是说不能通过 SVG 的 innerHTML 属性直接赋值

  if (node.namespaceURI === Namespaces.svg && !('innerHTML' in node)) {
    reusableSVGContainer =
      reusableSVGContainer || document.createElement('div');
    reusableSVGContainer.innerHTML = '<svg>' + html + '</svg>';
    const svgNode = reusableSVGContainer.firstChild;
    while (node.firstChild) {
      node.removeChild(node.firstChild);
    }
    while (svgNode.firstChild) {
      node.appendChild(svgNode.firstChild);
    }
  } else {
    node.innerHTML = html;
  }
});复制代码

updateProperties

/**
 * 应用 diff,更新属性
 * @param domElement
 * @param updatePayload
 * @param tag
 * @param lastRawProps
 * @param nextRawProps
 */
export function updateProperties(
  domElement: Element,
  updatePayload: Array<any>,
  tag: string,
  lastRawProps: Object,
  nextRawProps: Object,
): void {
  // 更新名称前的选中状态
  // 在更新过程中,可能会有多个被选中项
  // 当一个选中的单选框改变名称时,浏览器会使其他的单选框的选中状态为 false
  if (
    tag === 'input' &&
    nextRawProps.type === 'radio' &&
    nextRawProps.name != null
  ) {
    ReactDOMInputUpdateChecked(domElement, nextRawProps); // 更新 radio 的 Checked 状态
  }

  const wasCustomComponentTag = isCustomComponent(tag, lastRawProps);
  const isCustomComponentTag = isCustomComponent(tag, nextRawProps);
  // 应用 diff.
  updateDOMProperties(
    domElement,
    updatePayload,
    wasCustomComponentTag,
    isCustomComponentTag,
  );

  // TODO: Ensure that an update gets scheduled if any of the special props
  // changed.
  switch (tag) {
    case 'input':
      // 在更新 props 之后更新 input 的包装器。
      // 这必须发生在 `updateDOMProperties` 之后。
      // 否则,HTML5 输入验证将发出警告并阻止新值的分配。
      ReactDOMInputUpdateWrapper(domElement, nextRawProps);
      break;
    case 'textarea':
      ReactDOMTextareaUpdateWrapper(domElement, nextRawProps);
      break;
    case 'select':
      // <select> 的值更新需要在 <option> 子元素 reconciliation 之后发生
      ReactDOMSelectPostUpdateWrapper(domElement, nextRawProps);
      break;
  }
}复制代码


updateDOMProperties

function updateDOMProperties(
  domElement: Element,
  updatePayload: Array<any>,
  wasCustomComponentTag: boolean,
  isCustomComponentTag: boolean,
): void {
  // TODO: Handle wasCustomComponentTag
  for (let i = 0; i < updatePayload.length; i += 2) {
    const propKey = updatePayload[i];
    const propValue = updatePayload[i + 1];
    if (propKey === STYLE) {
      setValueForStyles(domElement, propValue);
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
      setInnerHTML(domElement, propValue);
    } else if (propKey === CHILDREN) {
      setTextContent(domElement, propValue);
    } else {
      setValueForProperty(domElement, propKey, propValue, isCustomComponentTag);
    }
  }
}复制代码

由于当前元素已经存在于 dom 中了,因此只需要根据传入的数据进行更新就行了。

diffProperties

/**
 * 计算两个对象之间的差异。
 * 为什么需要更新,无非三种情况:
 * 1. 之前有,之后没有,需要删除
 * 2. 之前没有,之后有,需要新增
 * 3. 前后值不一样,需要更新
 *
 * 但在 react 的实现中,新增和更新的逻辑都是一样,因此可以合并:
 * 1. 之前有,之后没有,需要删除
 * 2. 前后值不一样,需要更新
 *
 * @param domElement
 * @param tag
 * @param lastRawProps
 * @param nextRawProps
 * @param rootContainerElement
 * @returns {Array<*>}
 */
export function diffProperties(
  domElement: Element,
  tag: string,
  lastRawProps: Object,
  nextRawProps: Object,
  rootContainerElement: Element | Document,
): null | Array<mixed> {
  if (__DEV__) {
    validatePropertiesInDevelopment(tag, nextRawProps);
  }

  let updatePayload: null | Array<any> = null;

  let lastProps: Object;
  let nextProps: Object;
  switch (tag) {
    case 'input':
      lastProps = ReactDOMInputGetHostProps(domElement, lastRawProps);
      nextProps = ReactDOMInputGetHostProps(domElement, nextRawProps);
      updatePayload = [];
      break;
    case 'option':
      lastProps = ReactDOMOptionGetHostProps(domElement, lastRawProps);
      nextProps = ReactDOMOptionGetHostProps(domElement, nextRawProps);
      updatePayload = [];
      break;
    case 'select':
      lastProps = ReactDOMSelectGetHostProps(domElement, lastRawProps);
      nextProps = ReactDOMSelectGetHostProps(domElement, nextRawProps);
      updatePayload = [];
      break;
    case 'textarea':
      lastProps = ReactDOMTextareaGetHostProps(domElement, lastRawProps);
      nextProps = ReactDOMTextareaGetHostProps(domElement, nextRawProps);
      updatePayload = [];
      break;
    default:
      lastProps = lastRawProps;
      nextProps = nextRawProps;
      if (
        typeof lastProps.onClick !== 'function' &&
        typeof nextProps.onClick === 'function'
      ) {
        // TODO: This cast may not be sound for SVG, MathML or custom elements.
        trapClickOnNonInteractiveElement(((domElement: any): HTMLElement));
      }
      break;
  }

  assertValidProps(tag, nextProps);

  let propKey;
  let styleName;
  let styleUpdates = null; // 用以记录需要更新的样式,1. 需要清除的样式 2. 新增的或者值改变的样式
  for (propKey in lastProps) {
    // nextProps 有此属性不处理
    // lastProps 没有此属性不处理
    // lastProps 此属性值为 null 或 undefined 不处理
    // 也就是说,只处理 lastProps 有,但 nextProps 没有的,且 lastProps 中值不为 null 或 undefined 的
    // 更直白点说就是重置需要删除的属性
    if (
      nextProps.hasOwnProperty(propKey) ||
      !lastProps.hasOwnProperty(propKey) ||
      lastProps[propKey] == null
    ) {
      continue;
    }
    if (propKey === STYLE) { // 处理 style 属性
      const lastStyle = lastProps[propKey];
      for (styleName in lastStyle) {
        if (lastStyle.hasOwnProperty(styleName)) {
          if (!styleUpdates) {
            styleUpdates = {};
          }
          styleUpdates[styleName] = ''; // 清除之前的样式
        }
      }
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML || propKey === CHILDREN) {
      // Noop. This is handled by the clear text mechanism.
    } else if (
      propKey === SUPPRESS_CONTENT_EDITABLE_WARNING ||
      propKey === SUPPRESS_HYDRATION_WARNING
    ) {
      // Noop
    } else if (propKey === AUTOFOCUS) {
      // 无操作。无论如何,它都不能在更新上工作。
    } else if (registrationNameModules.hasOwnProperty(propKey)) {
      // 这是一个特例。如果任何侦听器更新,
      // 我们需要确保 "current" props 指针得到更新,
      // 因此我们需要一个提交来更新此元素。
      if (!updatePayload) {
        updatePayload = [];
      }
    } else {
      // 对于所有其他已删除的属性,我们将其添加到队列中。相反,我们在提交阶段使用白名单。
      (updatePayload = updatePayload || []).push(propKey, null);
    }
  }
  for (propKey in nextProps) {
    const nextProp = nextProps[propKey];
    const lastProp = lastProps != null ? lastProps[propKey] : undefined;
    // nextProps 没有的属性不添加到更新
    // nextProp 和 lastProp 相等不处理
    // nextProp 和 lastProp 都为 null 或 undefined 不处理
    // 也就是说值处理 nextProps 有的,值不为 null 或 undefined,且 nextProp 和 lastProp 的情况
    // 更直白点说就是更新值发生改变的属性
    if (
      !nextProps.hasOwnProperty(propKey) ||
      nextProp === lastProp ||
      (nextProp == null && lastProp == null)
    ) {
      continue;
    }
    if (propKey === STYLE) {
      if (__DEV__) {
        if (nextProp) {
          // Freeze the next style object so that we can assume it won't be
          // mutated. We have already warned for this in the past.
          Object.freeze(nextProp);
        }
      }
      if (lastProp) {
        // Unset styles on `lastProp` but not on `nextProp`.
        for (styleName in lastProp) {
          if ( // lastProp 有这个属性,并且 nextProp 不存在或者没有此属性。
            lastProp.hasOwnProperty(styleName) &&
            (!nextProp || !nextProp.hasOwnProperty(styleName))
          ) {
            if (!styleUpdates) {
              styleUpdates = {};
            }
            styleUpdates[styleName] = ''; // 置空样式属性值
          }
        }
        // 更新自 `lastProp` 以来更改的样式。
        for (styleName in nextProp) {
          // nextProp 存在的样式属性,且前后值不一样
          if (
            nextProp.hasOwnProperty(styleName) &&
            lastProp[styleName] !== nextProp[styleName]
          ) {
            if (!styleUpdates) {
              styleUpdates = {};
            }
            styleUpdates[styleName] = nextProp[styleName];
          }
        }
      } else { // 以前不存在 style 属性
        // 依赖于 `updateStylesByID` 而不是 `styleUpdates`.
        if (!styleUpdates) {
          if (!updatePayload) {
            updatePayload = [];
          }
          updatePayload.push(propKey, styleUpdates); // 等同于 updatePayload.push(propKey, null);
        }
        styleUpdates = nextProp;
      }
    } else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
      const nextHtml = nextProp ? nextProp[HTML] : undefined; // 取出 .__html 中存储的 html 字符串
      const lastHtml = lastProp ? lastProp[HTML] : undefined; // 取出 .__html 中存储的 html 字符串
      if (nextHtml != null) {
        if (lastHtml !== nextHtml) {
          (updatePayload = updatePayload || []).push(propKey, '' + nextHtml); // 需要更新的 html
        }
      } else {
        // TODO: It might be too late to clear this if we have children
        // inserted already.
      }
    } else if (propKey === CHILDREN) {
      // 处理纯文本
      if (
        lastProp !== nextProp &&
        (typeof nextProp === 'string' || typeof nextProp === 'number')
      ) {
        (updatePayload = updatePayload || []).push(propKey, '' + nextProp);
      }
    } else if (
      propKey === SUPPRESS_CONTENT_EDITABLE_WARNING ||
      propKey === SUPPRESS_HYDRATION_WARNING
    ) {
      // Noop
    } else if (registrationNameModules.hasOwnProperty(propKey)) {
      if (nextProp != null) {
        // We eagerly listen to this even though we haven't committed yet.
        if (__DEV__ && typeof nextProp !== 'function') {
          warnForInvalidEventListener(propKey, nextProp);
        }
        ensureListeningTo(rootContainerElement, propKey);
      }
      if (!updatePayload && lastProp !== nextProp) {
        // 这是一个特例。如果任何侦听器更新,
        // 我们需要确保 "current" props 指针得到更新,
        // 因此我们需要一个提交来更新此元素。
        updatePayload = [];
      }
    } else {
      // 对于任何其他属性,我们总是将其添加到队列中,然后在提交期间使用白名单过滤掉。
      (updatePayload = updatePayload || []).push(propKey, nextProp);
    }
  }
  if (styleUpdates) {
    if (__DEV__) {
      validateShorthandPropertyCollisionInDev(styleUpdates, nextProps[STYLE]);
    }
    (updatePayload = updatePayload || []).push(STYLE, styleUpdates); // 添加样式更新
  }
  return updatePayload;
}复制代码

diffHydratedProperties

diffHydratedText

计算文本节点之间的差异

/**
 * 计算文本节点之间的差异。
 * @param textNode
 * @param text
 * @returns {boolean}
 */
function diffHydratedText(textNode: Text, text: string): boolean {
  const isDifferent = textNode.nodeValue !== text;
  return isDifferent;
}复制代码

restoreControlledState

/**
 * 恢复受控状态
 * @param domElement
 * @param tag
 * @param props
 */
export function restoreControlledState(
  domElement: Element,
  tag: string,
  props: Object,
): void {
  switch (tag) {
    case 'input':
      ReactDOMInputRestoreControlledState(domElement, props);
      return;
    case 'textarea':
      ReactDOMTextareaRestoreControlledState(domElement, props);
      return;
    case 'select':
      ReactDOMSelectRestoreControlledState(domElement, props);
      return;
  }
}复制代码

遗留问题

上面创建的元素只是创建了一个,如何创建一个 DOM 树?


转载于:https://juejin.im/post/5cf7113251882524156c9b5c

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值