谈一谈React18的服务端渲染

最近笔者腾出了大把的时间,学习了一下React18。

React18带来了许多新特性,其中包括SSR的增强和Suspense的增强。本文就来具体剖析一下这两部分的变化。

官方React18 SSR Discussion

那么,首先来详细讲一下SSR。

什么是SSR

SSR并不是最近才发明的,早在互联网初期,我们看到的web页面几乎都是服务端渲染的,诸如Jsp、ASP、Java等。当时前后端开发是一体的,UI通过模版来表现,并在服务端处理成html发送到浏览器。如今我们所说的SSR往往指的是在使用前端组件化的框架如React、Vue时,服务端渲染组件并最终生成html发送到客户端。

对比客户端渲染(CSR)主要优势就是:

  1. SEO友好:html在服务端渲染,搜索引擎能抓取页面内容,SEO效果好。而CSR页面往往html是空的。
  2. 首屏加载速度快:用户请求页面后直接返回完整的HTML并展示。不需要等待客户端下载完整js并执行,减少白屏时间。

缺点就是,需要node服务器,不能像CSR应用那样可以直接放到CDN上。

React技术栈的同学在想到SSR时,往往第一反应就是Next.js。Next.js是一套完整的SSR应用开发框架,包含路由系统、数据获取、SSR、SSG、图片脚本优化等。它底层是基于React,其中SSR的实现也是React本身提供的能力。

这是因为,React具有同构特性,它允许相同的代码在服务器和客户端上运行。

React18之前的旧SSR

React提供了一些Server Api帮助在服务端渲染html,并在客户端水合(Hydration)。

简述下这个过程:

  1. 接收到一个请求后,React在server端执行Server Api,将React组件(比如<App />)渲染成string或者stream形式的html。这个过程是服务端的React执行的。此时,html中只包含静态的内容,不包含事件监听器等,页面无法交互。

  2. 当这个html渲染到web端时,浏览器去执行js脚本,此时开始由客户端的React接管了。执行hydrateRoot方法,会将该html和React组件树(比如上一步的<App />)进行Hydration处理。

  3. Hydration流程,和我们熟悉的createRoot触发的首次渲染流程一样。会执行到render阶段,只不过Hydration会复用dom,将创建的fiber与dom关联,并且在completeWork时不创建dom。执行到commit阶段时,会绑定上事件监听器。这样流程结束后,页面就有了交互性。

一句话概述就是:server端React渲染出首屏页面,浏览器可以直接展示,但是交互不了。Hydration就是web端React接管页面的过程,并让页面变得可交互

其中服务端渲染部分renderToString原理:就是递归遍历组件树,拼接成html字符串。

客户端水合hydrateRoot原理如上。代码不赘述。

参考文章:React SSR流程分析

手动实现旧SSR

下面写一个简单的demo,来实现SSR。

为了方便实现同一套组件,既在服务端渲染,又在客户端hydrate。所以我将服务端代码和客户端代码放在同一个项目里,创建了两个webpack配置文件。这样它们都方便导入App.jsx

参考官方文档,首先先实现App.jsx

import React, { useState } from "react";

export default function App() {
  return <Count />;
}

function Count() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <h1>测试SSR</h1>
      <div>计数器:{count}</div>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

App组件嵌套了一个简单的计数器组件。

我们打算在server上通过renderToString方法直接渲染出一个包含客户端执行脚本的html。这样的话浏览器渲染后可以直接执行。

所以我们要实现一下客户端待执行的脚本index.js

import React from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App.jsx";

// ...
// 开始hydrate
function hydrate() {
  hydrateRoot(document.getElementById("app"), <App />);
}
// ...

客户端脚本写完了,使用webpack打包客户端代码,生成output/bundle.js

然后在服务端server.js里,设置请求路由,并通过renderToString方法返回html。html中包含脚本bundle.js

const React = require("react");
const Koa = require("koa");

// ...
app.use(serve("./output"));

router.get("/", async (ctx, next) => {
  ctx.set("content-type", "text/html");
  ctx.status = 200;

  const appString = renderToString(<App />);
  ctx.body = `<html>
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <link rel="stylesheet" href="/styles.css"></link>
        <title>My App</title>
      </head>
      <body>
        <div id="app">
          ${appString}
        </div>
      </body>
      <script src="/bundle.js" async></script>
    </html>`;
});
// ...

app.listen(888);

开发完了,测试下。

可以正常展示计数和点击+1。如果我们把index.js里面的hydrateRoot方法注释掉,即不水合,会发现,上面的按钮点击已经没有反应了。

思考:React的SSR和传统后端语言的SSR的区别是什么呢?

最明显的区别就是传统SSR是多页应用,页面通过刷新跳转。请求一个地址返回一个html页面。体验较差。

而React的SSR是同构应用,即请求地址的首屏由服务端渲染,此后客户端React接管,形成单页应用。体验很优秀。

为什么用Next.js

既然React已经支持了SSR,那为什么还需要Next.js这样的技术呢?(这里主要说的是Next.js的Pages Router)。

开发体验

从上面的例子可以看出来,手动通过React实现SSR,在一个项目里面,要自己控制好前后端代码的边界。哪些是前端的代码,哪些是后端的代码;要自己考虑怎么实现前端和后端都可以运行的代码;并且还需要分开编译,整体开发体验很差。而使用Next.js就不需要考虑这些,Next.js已经帮我们处理好了。

路由

也是上面的例子,我们仅仅实现了一个基础的ssr应用,没有包含多页面路由,而路由是最重要的功能之一。那ssr如果要支持多页面怎么做?

好在react-router支持ssr。在服务端使用StaticRouter,在客户端使用BrowserRouter。但是路由功能很多呀,两边适配起来也不容易。而Next.js则通过基于文件的路由系统将这一系列复杂工作全都处理完了,我们开箱即用即可。

异步数据获取

上面的例子也没有展现如何在ssr时获取异步数据,并依据数据渲染页面。这里要考虑的,一是数据要在服务端获取,二是数据也要注入到客户端,否则水合会出问题*(比如ssr获取数据为100个元素的list,并渲染出100个div。客户端如果未获取这个props数据,那客户端会渲染空列表)*。

为了实现两端异步数据获取的同构,我们需要借助于react-router的loader功能。在服务端,在loader中调用组件暴露的静态方法来获取异步数据,并作为props传递到组件。

// router
[
  // ...
  { 
    path: "/posts",
    component: Posts,
    loader: isServer ? Posts.getServerData : Posts.getClientData,
  }
  // ...
]

// Posts组件
function Posts({ posts }) {
  return <div>{posts.map((p) => {/* */})}</div>
}

Posts.getServerData = async () => {
  const posts = await getPosts()
  return { posts };
}

生成ssr的html时,暴露数据到<script>标签下。

ctx.body = `<html>
  // ...
  <script id="_POST_DATA_" type="application/json">
    [{ id: 1, title: "Sumish" }, ...]
  </script>
  // ...
</html>`;

客户端,需要从html内<script>标签下获取数据并注入。

Posts.getClientData = () => {
  const posts = JSON.parse(document.getElementById("__NEXT_DATA__").textContent);
  return { posts };
}

Next.js的方案可能与上面类似,它已经完全封装好该功能,只需要提供一个getServerSideProps函数并实现即可。

CSS处理

上面的例子中,我们将css放在了html上。这样是可行的。但是如果我们想要使用less这样的css预处理器,甚至是emotion这样的css-in-js技术,ssr就不好处理了。因为node上无法执行dom操作。ssr处理css,必须要将css内容生成到html字符串中。好在Next.js也为我们封装好了。

单页体验

上面的例子中,我们在水合后交由客户端React处理后续操作,就为了实现SPA的良好体验。但实现上还存在问题。比如异步数据获取:SSR时,异步数据是在服务端获取并在客户端注入。但SPA情况下,数据则必须在客户端获取。要应对这种情况,代码还需要去fix。而Next.js默认已经处理好SSR场景和SPA场景下的异步数据获取逻辑。其它功能也一样处理完善。

其他优化

除此之外,Next.js还提供了很多优化和便利。包括SEO处理、SSG静态生成、TypeScript支持、打包优化等等。并且Next.js始终跟进最新的React能力,从而提供性能更好的应用。

参考文章:React ssr原理

React18的新SSR

React18下,SSR得到了很多增强特性。在此之前的React版本中,正如我们上面手动实现的例子,实现SSR需要:

  1. 在服务端,整个应用从数据源fetch数据;整个应用渲染成字符串;返回html的response。

  2. 在客户端,加载javascript;执行javascript进行Hydration。

这两步骤缺一不可,按顺序执行,并且内部也是同步执行的。比如服务端必须获取完数据,并完整渲染、客户端接收到完整html再整个执行Hydration。可想而知,这个过程并不高效:

  • 必须获取所有数据,才能显示内容

    比如某一个组件获取数据比较耗时,那它就会阻塞后续所有步骤,导致页面白屏时间很长。除非说把这个数据获取从SSR中排除,丢给CSR去处理。(CSR大家都懂,必须等待js执行完才开始获取数据)。

  • 必须加载所有js,才能开始水合

    比如某个组件交互逻辑比较复杂,js代码比较多。由于React只能一次性完成Hydration,那么你必须等待所有组件js都加载完成,才能开始Hydration。你可能会选择代码拆分,但React没法做到Hydration过程中按需加载组件代码。为了妥协,你只能把这个组件从SSR中移除,并使用代码拆分。

  • 必须完成整个水合过程,页面才能交互

    比如某个组件渲染逻辑很复杂,hydration可能耗时比较久。那么你必须等到所有组件hydration结束,才能与其中一个组件有交互。

那新的SSR架构怎么解决这些问题呢?React18带来了流式Html和选择性水合:

  • Streaming Html:在服务端,使用renderToPipeableStream代替renderToString渲染,可以让html分段传输到浏览器,让浏览器可以更快速、平滑的显示内容。

  • Selective Hydration:在客户端,可以针对已经完成渲染的区域进行水合,而不需要和原来一样等待整个页面加载完成以及所有js加载完成,才能开始水合。

在讲这两个特性之前,先了解一下新的Suspense组件。因为它们的实现离不开Suspense

Suspense组件

在React18之前,Suspense组件的功能很单一,我们使用它的场景就是和React.lazy组合,来实现代码拆分组件懒加载

import React, { Suspense, lazy } from 'react';

const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

例子中,React.lazy + 动态导入语法(() => import(/*...*/))让LazyComponent组件的代码在打包时被单独拎出去。并在需要到该组件时,才去加载对应的组件代码。Suspense组件会在该组件代码没有被加载完成前,展示fallback中的备用组件。

在React18的并发特性下,Suspense组件得到了诸多增强特性。它的定义改为:**在组件树中,如果某一部分还没准备好被显示出来,Suspense可以让你指定它的loading状态。**可以看出来,它包含了之前的能力,但是明显范围更大(没加载完成=>没准备好显示)。

Suspense的全部新特性,以及原理、升级原因,详细可以看一下Suspense功能的RFC。这里就不再赘述。

这里重点关注一下与SSR有关的新特性,之前Suspense无法应用于SSR。而在React18使用renderToPipeableStream这个新的SSR渲染器的话,它可以与Suspense集成:**新渲染器创建一个stream来发送html到客户端,SSR过程中,当使用Suspense包裹的这部分组件树还没准备好渲染时,可以先发送fallback的内容。当这部分组件准备好后,React会在当前流中,发送html和内联script脚本到客户端,来将客户端的dom修正。**这一部分都是SSR实现的,即使客户端js完全没有执行,也不影响。

流式传输Html

上面提到renderToPipeableStream创建一个stream来发送html到客户端。这个是怎么实现的呢?

扩展一下,服务端发送流式数据到客户端有三种方式:

  1. Server-Send Events:SSE是一种服务器推送技术,允许服务器向客户端发送事件。通过设置请求头Content-Type: text/event-stream,请求体发送二进制流实现。

  2. 分块传输(Chunked Transfer Encoding):可以将不确定内容长度的数据块,分多次的传输给客户端,通过设置Transfer-Encoding: chunked,请求体发送二进制流实现。与SSE的区别是SSE主要用于发送事件。

  3. WebSocket:一种服务器和客户端的双向通信协议。用来实现实时通信。与SSE的区别是SSE不支持客户端发送事件到服务端。

新渲染器看起来很适合使用分块传输的方式发送html,因为html内容是不确定的,随着渲染过程的进行才慢慢确定。实际上React也是使用的分块传输技术。很简单,写个接口测试一下:

router.get("/streaming", async (ctx) => {
  ctx.set("content-type", "text/html");
  ctx.set("transfer-encoding", "chunked");
  ctx.status = 200;
  // 发送第一段html
  ctx.res.write("<!DOCTYPE html><html><head><title>Streaming</title></head><body><div id='app'><p>First Segment</p>");
  // 延迟3秒方便看效果
  await new Promise((resolve) => setTimeout(resolve, 3000));
  // 发送第二段html
  ctx.res.write("<p>Second Segment</p></div></body></html>");
  ctx.res.end();
});

浏览器上直接调用该接口,查看渲染出来的页面如下:

react-ssr-streaming

可以看到,现代浏览器对分块传输的html支持还是很好的,即使html内容不全,也自动补全了。并在后一段发送过来时追加上去。整个过程页面显示很平滑。

流式HTML + Suspense

基于此,React中在服务器端又是怎么区分哪些需要分块去传输呢?答案是Suspense

数据获取导致的Suspense

直接拿官方demo来说(可以自己fork后运行,如果不行就下载到本地)。主要的组件结构如下:

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

页面包含顶部导航、左侧侧边栏、右侧内容区,右侧内容区包含上面的Post内容,和用Suspense包裹的Comments评论列表。如图:

Comments需要一个耗时的获取数据的操作,所以使用Suspense包裹。并且在SSR首次工作到这里时,由于Comments没有获取完成数据,所以它没有准备OK。Suspense将fallback的组件提交出去。最终SSR首次提交到客户端的stream Html如下:

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>

接着,当Comments终于完成了自己的渲染。React将发送另一段html并包含一段内联js脚本,来将新的内容替换到Spinner的位置。

<!-- 实际的Comment组件内容 -->
<div hidden id="comments">
  <p>First comment</p>
  <p>Second comment</p>
</div>

<script>
  // 非常简化的实现
  const commentsDiv = document.getElementById('comments');
  document.getElementById('sections-spinner').replaceChildren(commentsDiv);

  commentsDiv._reactRetry();
</script>

同时注意到最后一句commentsDiv._reactRetry(),是为了重启Comment组件所属的Suspense边界的渲染任务。后面讲到选择性水合时会讲解。

由此可见,我们在组件树中也可以插入任意多个Suspense的组件,这些组件会在自己内容准备OK的时候,独立的发送一段html及js到客户端以完成“修正”。它们不是自上而下的,下面的无需等待上面的组件渲染完成。

由于Suspense必须要知道组件数据准备OK的时机,所以数据获取逻辑必须要与Suspense打通。但是到目前为止,React还没有提供更合适的方式来打通它们。官方推荐使用Server Components(目前仍是实验性的)或者是三方的框架如Next.jsRelay等,它们可能会提供对应的封装。

查看官方的示例,它能实现上述效果,原理是组件在请求数据时抛出一个Promise,这个抛出的Promise会被上方的Suspense组件捕获,此时Suspense组件就会认为子树还未准备好显示,就会输出fallback的内容,从而让SSR继续。当这个Promise被解决(resolve)时,Suspense会重新渲染子树。注意:这个方法只是为了演示使用,不能用于生产防止意外情况!

代码分包导致的Suspense

React18之前,React.lazy无法应用于SSR实现代码分包,如果之前希望SSR的同时,客户端还能代码分包。那就只能将需要代码分包的组件从SSR移除,并且在客户端Hydration之前加载这些js。那代码分包的意义何在呢?

但现在不存在这个问题了。用法是一样的,比如我们将SideBar组件懒加载:

import { lazy } from 'react';

const Sidebar = lazy(() => import('./Sidebar'));

// ...
<Suspense fallback={<Spinner />}>
  <Sidebar />
</Suspense>
// ...

此时,由于SideBar组件懒加载。首次SSR渲染时,如果组件未准备OK,Suspense会输出fallback内容,直到SideBar组件加载完成再发送新的内容。

流式SSR存在降级逻辑,假如Suspense边界内的组件SSR抛出异常,SSR失败。那么客户端会接管这部分组件渲染逻辑。

选择性水合 + Suspense

React18之后,客户端支持了选择性水合,不再需要等待html及所有组件加载完成后一次性的执行Hydration过程。

原理是:客户端在执行Hydration阶段时,会将那些已经完成渲染的组件hydate,使之可以交互。遇到Suspense组件时,判断它的子树是否已经完成了渲染,如果未完成则跳过hydrate。等待组件渲染完成后,再恢复这部分子树的hydrate。

分三种情况去讨论:

1. 客户端加载完成,客户端水合时,发现服务端还在分段传输

常见的情况就是服务端组件还在获取数据等待渲染,而客户端js都已经开始执行了。此时选择性水合与流式HTML协作,不管是由于数据获取还是代码分包导致html需要分段传输,都没有关系。水合时会跳过这些还没有html的组件。

前面的例子我们提到,Comment组件在获取到数据,延迟的渲染完成后重新发送了一段html和内联脚本,内联脚本最后一句就是调用dom元素上的_reactRetry()。这个方法就是重启Suspense边界的渲染任务的,因为这个边界内的子树有可能全部渲染完成了。我们搜索下源码,发现它是在beginWork阶段绑定到Suspense的dom元素上的。

// ReactFiberBeginWork.js
function beginWork(current, workInProgress) {
  // ...
  switch (workInProgress.tag) {
    // ...
    case SuspenseComponent:
      // ...
      updateSuspenseComponent();
    // ...
  }
  // ...
}

function updateSuspenseComponent() {
  // ...
  if (/* ... */) {
    updateDehydratedSuspenseComponent();
  }
  // ...
}

function updateDehydratedSuspenseComponent(suspenseInstance) {
  // ...
  if (isSuspenseInstancePending(suspenseInstance)) {
    const retry = retryDehydratedSuspenseBoundary.bind(null, current);
    registerSuspenseInstanceRetry(suspenseInstance, retry);
    return null;
  }
  // ...
}

// ReactFiberConfigDOM.js
export function registerSuspenseInstanceRetry(
  instance: SuspenseInstance,
  callback: () => void,
) {
  instance._reactRetry = callback;
}

总结一下,客户端选择性水合时,未加载html的Suspense的子树会延迟水合。等服务端流发送这段html后,再去重启渲染任务,完成水合。

2. 服务端渲染完成,客户端水合时,发现组件代码还缺失

常见的情况就是我们为了不让一次性加载的js过于庞大,会选择将部分组件进行代码分包。当服务端渲染已经完成时,客户端开始执行水合,发现部分组件代码还没加载。

这就是选择性水合的另一个使用场景。上节中代码分包的例子中,SideBar组件代码分包。当水合时,虽然它的ssr的html已经存在,但由于代码还没加载完成,仍然标记为未完成渲染。它外部的Suspense会跳过它。从而让其他部分先参与水合。

SideBar的组件代码终于完成了加载,React会重新渲染并完成该部分的Hydration。

它的原理和前面《数据获取导致的Suspense》中,实现数据获取和重启渲染原理比较像。React.lazy方法会生成一个LazyComponent,在beginWork阶段会挂载LazyComponent,调用其_init代码位置)方法,从而开始加载组件,并抛出Promise。抛出的Promise会被Suspense捕获,并在Promise被解决时,重新渲染

// ReactFiberBeginWork.js
function beginWork(current, workInProgress) {
  // ...
  switch (workInProgress.tag) {
    // ...
    case LazyComponent:
      // ...
      mountLazyComponent();
    // ...
  }
  // ...
}

function mountLazyComponent(current, workInProgress, elementType) {
  // ...
  const lazyComponent = elementType;
  const payload = lazyComponent._payload;
  const init = lazyComponent._init;
  // Lazy组件挂载时开始加载真实组件
  let Component = init(payload);
  // ...
}

// ReactLazy.js
function lazy(ctor: () => Promise<{ default: T }>) {
  return {
    _payload: {
      _status: -1,
      _result: ctor
    },
    _init: function lazyInitializer(payload) {
      const ctor = payload._result;
      // 开始加载组件
      const thenable = ctor();
      // 组件加载完成后设置到payload里,让外部读取
      thenable.then((module) => {
        // ...
        payload._result = module.default;
      })
      // 抛出Promise,被Suspense捕获
      throw payload._result;
    },
  } as LazyComponent
}

总结一下,客户端选择性水合时,代码未加载完成的Suspense的子树会延迟水合。等组件代码加载完成后,再去重启渲染任务,完成水合。

3. 用户已经开始交互,客户端水合还没结束

React18的并发模式,让渲染任务分割成时间切片去执行,从而让浏览器有时间去处理用户交互。那么就有一种可能性发生,那就是服务端html已经显示出来,但是客户端还没有完成水合,这时候用户点击了未完成水合的组件按钮,怎么办?

还是之前的例子,SideBarComments组件都被Suspense包裹,假如它们的代码都已经加载完成,按照顺序,SideBar会先被执行Hydration。如图:

假如用户点击了Comments组件。

由于点击交互优先级最高。React将以同步优先级立刻hydrate该Comments组件。

由于是同步的,Comments组件hydrate结束后,能同步执行事件处理器,从而响应用户交互。

等它们都结束后,重新恢复SideBar的Hydration。

原理是触发点击时,React判断当前组件是否被block,如果block了,则去同步的对该fiber执行Hydration(代码位置),等执行结束,再去实际的调用event回调。

水合过程是从HostRoot根组件、以及Suspense组件开始,它的粒度也是把这两类组件作为整体。即如果Suspense组件下的Comments组件的第一个Comment被点击,那么这个Suspense整体都会同步Hydration,而不是只水合第一个Comment。

全文总结

本文写的很长,从老的SSR原理、实现、存在的问题,讲到新SSR特性的原理、功能和解决的问题。讲得很多,可能需要一段时间才能消化。

最后再总结一下两个SSR主要特性:

  • 流式HTML:让你可以尽早的发送一些html到客户端,并在其他耗时的内容准备ok后,再次发送到客户端,从而更快速、平滑的显示内容。

  • 选择性水合:让你尽可能早的水合已经准备好的内容,使其可交互。并在其他内容准备好后立即水合。并且支持优先水合正在交互的部分。

再回顾一下React老SSR解决不了的三个问题,在新SSR特性下,是如何被解决的:

  • 必须获取所有数据,才能显示内容

    流式HTML的出现,让我们不再需要等待所有数据获取。

  • 必须加载所有js,才能开始水合

    水合不再需要所有js加载完成,所以我们可以使用代码分包,让较大的js块从初始的bundle分离出去,从而让水合更快开始。

  • 必须完成整个水合过程,页面才能交互

    水合不再是整体进行,而是可以部分完成。水合完成的部分可以交互。未完成的部分,如果用户与其交互,React会以最高优先级,立刻让其完成水合,并立刻响应交互。所以用户感觉它就是瞬间完成的。

可以试想下,老SSR的顺序串行,且相互依赖的fetch data => SSR => load js => Hydration低效运行模式,在新SSR特性下,转变为并行、异步、流式渲染的高效运行模式下,性能会有多大的提升。让我们拭目以待吧!

本文未介绍到的相关内容包括以下几部分,后续可能考虑单独出一些文章来介绍。

  • renderToPipeableStream的实现原理
  • Suspense的原理及其它特性
  • 客户端Hydration的原理
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值